From 208d34abc7567c52d11b714e38a96b20a3714b2e Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 01:48:22 +0400
Subject: [PATCH 1/9] chunk bug
---
frontend/src/components/Chunks.test.tsx | 5 ++-
frontend/src/components/Chunks.tsx | 17 +++++++--
.../components/graph/GraphSourceView.test.tsx | 27 +++++++++++--
.../src/components/graph/GraphSourceView.tsx | 15 +++++---
.../src/components/tree/TreeBrowser.test.tsx | 38 +++++++++++++++++++
frontend/src/components/tree/TreeBrowser.tsx | 15 +++++---
6 files changed, 99 insertions(+), 18 deletions(-)
diff --git a/frontend/src/components/Chunks.test.tsx b/frontend/src/components/Chunks.test.tsx
index 42a1e372..93460cda 100644
--- a/frontend/src/components/Chunks.test.tsx
+++ b/frontend/src/components/Chunks.test.tsx
@@ -495,7 +495,8 @@ describe('Chunks', () => {
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
- await render({ embedded: true });
+ const onOpenChunkChange = vi.fn();
+ await render({ embedded: true, onOpenChunkChange });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
// After the save, the open position holds nothing (or another chunk).
@@ -509,6 +510,8 @@ describe('Chunks', () => {
expect(container.querySelector('h2')?.textContent).toBe('Rewritten');
expect(container.textContent).toContain('chunkPositionUnplaced');
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
+ // An embedding host draws the crumb: still open, but with no number.
+ expect(onOpenChunkChange).toHaveBeenLastCalledWith('unplaced');
});
it('deleting the only chunk on the last page lands on the page before', async () => {
diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx
index ae6e0ddc..0ab28f53 100644
--- a/frontend/src/components/Chunks.tsx
+++ b/frontend/src/components/Chunks.tsx
@@ -35,6 +35,13 @@ import { Pagination } from './ui/pagination';
/** Chunks per page: divisible by 2, 3 and 4 columns, so a page fills the grid. */
const PAGE_SIZE_OPTIONS = [12, 24, 48];
+/**
+ * Where the open chunk is, as reported to an embedding host: its 1-based
+ * position, 'unplaced' while it is open but its place in the list is unknown
+ * (a save moved it off every probed position), or null while the grid shows.
+ */
+export type OpenChunkPosition = number | 'unplaced' | null;
+
/** Lets the host's crumbs close the open chunk (see TreeBrowser). */
export interface ChunksController {
closeChunk: () => void;
@@ -79,8 +86,8 @@ interface ChunksProps {
* (`onOpenChunkChange`) and close it (`controllerRef`).
*/
embedded?: boolean;
- /** The open chunk's position (1-based), or null while the grid shows. */
- onOpenChunkChange?: (position: number | null) => void;
+ /** Where the open chunk is; see {@link OpenChunkPosition}. */
+ onOpenChunkChange?: (position: OpenChunkPosition) => void;
controllerRef?: React.MutableRefObject;
}
@@ -296,7 +303,11 @@ const Chunks: React.FC = ({
closeChunk: () => closeChunk(),
}));
- const openChunkPosition = openChunk ? openPosition : null;
+ const openChunkPosition: OpenChunkPosition = !openChunk
+ ? null
+ : positionLost
+ ? 'unplaced'
+ : openPosition;
const onOpenChunkChangeRef = useRef(onOpenChunkChange);
useEffect(() => {
onOpenChunkChangeRef.current = onOpenChunkChange;
diff --git a/frontend/src/components/graph/GraphSourceView.test.tsx b/frontend/src/components/graph/GraphSourceView.test.tsx
index 87da9a5f..dceedf42 100644
--- a/frontend/src/components/graph/GraphSourceView.test.tsx
+++ b/frontend/src/components/graph/GraphSourceView.test.tsx
@@ -73,13 +73,13 @@ vi.mock('../Chunks', async () => {
default: (props: {
embedded?: boolean;
documentId: string;
- onOpenChunkChange?: (position: number | null) => void;
+ onOpenChunkChange?: (position: number | 'unplaced' | null) => void;
controllerRef?: { current: { closeChunk: () => boolean } | null };
}) => {
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(false);
const { onOpenChunkChange, controllerRef } = props;
useEffect(() => {
- onOpenChunkChange?.(open ? 2 : null);
+ onOpenChunkChange?.(open || null);
}, [open, onOpenChunkChange]);
if (controllerRef) {
controllerRef.current = {
@@ -92,9 +92,12 @@ vi.mock('../Chunks', async () => {
return (
{props.embedded ? 'embedded' : 'page'}
- setOpen(true)}>
+ setOpen(2)}>
OPEN CHUNK
+ setOpen('unplaced')}>
+ LOSE PLACE
+
);
},
@@ -463,6 +466,22 @@ describe('GraphSourceView', () => {
).find((b) => b.textContent === 'Key Accounts')!;
await act(async () => source.click());
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
+
+ // A saved chunk whose place is unknown keeps an unnumbered crumb, and the
+ // source crumb still closes it.
+ await act(async () => buttonByText('LOSE PLACE')!.click());
+ expect(crumbs()).toEqual([
+ 'settings.sources.label',
+ 'Key Accounts',
+ 'settings.sources.chunkCrumbUnplaced',
+ ]);
+ const again = Array.from(
+ container.querySelectorAll(
+ '[data-slot="breadcrumb-link"]',
+ ),
+ ).find((b) => b.textContent === 'Key Accounts')!;
+ await act(async () => again.click());
+ expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
});
it('uses the connector tree for a connector source', async () => {
diff --git a/frontend/src/components/graph/GraphSourceView.tsx b/frontend/src/components/graph/GraphSourceView.tsx
index 53816b1d..62faa677 100644
--- a/frontend/src/components/graph/GraphSourceView.tsx
+++ b/frontend/src/components/graph/GraphSourceView.tsx
@@ -13,7 +13,10 @@ import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatCount } from '../../utils/dateTimeUtils';
-import Chunks, { type ChunksController } from '../Chunks';
+import Chunks, {
+ type ChunksController,
+ type OpenChunkPosition,
+} from '../Chunks';
import ConnectorTree from '../ConnectorTree';
import FileTree from '../FileTree';
import GraphView, { type GraphLoadStatus } from '../GraphView';
@@ -80,9 +83,8 @@ export default function GraphSourceView({
// header shows it while that tab is open.
const [filesCrumbs, setFilesCrumbs] = useState([]);
// A flat source's open chunk (its crumb), reported by the chunk list.
- const [openChunkPosition, setOpenChunkPosition] = useState(
- null,
- );
+ const [openChunkPosition, setOpenChunkPosition] =
+ useState(null);
const chunksControllerRef = useRef(null);
useEffect(() => {
@@ -157,7 +159,10 @@ export default function GraphSourceView({
...(openChunkPosition !== null
? [
{
- label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
+ label:
+ openChunkPosition === 'unplaced'
+ ? t('settings.sources.chunkCrumbUnplaced')
+ : t('settings.sources.chunkCrumb', { n: openChunkPosition }),
},
]
: []),
diff --git a/frontend/src/components/tree/TreeBrowser.test.tsx b/frontend/src/components/tree/TreeBrowser.test.tsx
index 5be1e555..9d36bd54 100644
--- a/frontend/src/components/tree/TreeBrowser.test.tsx
+++ b/frontend/src/components/tree/TreeBrowser.test.tsx
@@ -42,6 +42,10 @@ vi.mock('../../api/services/userService', () => ({
chunks: state.chunks,
}),
})),
+ updateChunk: vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ chunk_id: 'c1' }),
+ })),
},
}));
@@ -252,6 +256,40 @@ describe('TreeBrowser', () => {
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
});
+ it('a saved chunk whose place is unknown keeps an unnumbered crumb', async () => {
+ state.chunks = [{ doc_id: 'c1', text: '# Rates', metadata: {} }];
+ await render(NESTED);
+ await openRow('readme.md');
+ await act(async () => tile()!.click());
+ const button = (text: string) =>
+ Array.from(document.body.querySelectorAll('button')).find(
+ (el) => el.textContent?.trim() === text,
+ )!;
+ await act(async () => button('modals.chunk.edit').click());
+ // After the save, the probed positions no longer hold the chunk.
+ state.chunks = [];
+ const field = document.body.querySelector(
+ '[role="dialog"] textarea',
+ )!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ 'value',
+ )!.set!.call(field, '# Rates edited');
+ field.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await act(async () => button('modals.chunk.save').click());
+ expect(crumbs()).toEqual([
+ SOURCES,
+ 'Contracts',
+ 'readme.md',
+ 'settings.sources.chunkCrumbUnplaced',
+ ]);
+
+ await clickCrumb('readme.md');
+ expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
+ });
+
it('a one-file source opens straight on its chunk list', async () => {
await render({ 'report.pdf': { type: 'pdf', display_name: 'Report' } });
expect(
diff --git a/frontend/src/components/tree/TreeBrowser.tsx b/frontend/src/components/tree/TreeBrowser.tsx
index e8d83388..d76d3b11 100644
--- a/frontend/src/components/tree/TreeBrowser.tsx
+++ b/frontend/src/components/tree/TreeBrowser.tsx
@@ -17,7 +17,10 @@ import { Eye, File, Folder } from 'lucide-react';
import { Button } from '../ui/button';
import { EmptyState } from '../ui/empty-state';
import { useLoaderState } from '../../hooks';
-import Chunks, { type ChunksController } from '../Chunks';
+import Chunks, {
+ type ChunksController,
+ type OpenChunkPosition,
+} from '../Chunks';
import PathHeader, { type Crumb } from './PathHeader';
import SourceNavigator from './SourceNavigator';
import SkeletonLoader from '../SkeletonLoader';
@@ -207,9 +210,8 @@ const TreeBrowser: React.FC = ({
} | null>(null);
const mountedRef = useRef(true);
// The open file's open chunk (its crumb), reported by Chunks.
- const [openChunkPosition, setOpenChunkPosition] = useState(
- null,
- );
+ const [openChunkPosition, setOpenChunkPosition] =
+ useState(null);
const chunksControllerRef = useRef(null);
useEffect(
@@ -587,7 +589,10 @@ const TreeBrowser: React.FC = ({
...(chunkOpen
? [
{
- label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
+ label:
+ openChunkPosition === 'unplaced'
+ ? t('settings.sources.chunkCrumbUnplaced')
+ : t('settings.sources.chunkCrumb', { n: openChunkPosition }),
},
]
: []),
From 98afa5d93ce15579fcbb18c27b4bdcea8ddd0425 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 10:42:39 +0400
Subject: [PATCH 2/9] Roles audit and revamp
---
docsgpt/agents/default_tools.py | 27 +-
docsgpt/agents/headless_runner.py | 9 +-
docsgpt/agents/tool_executor.py | 74 +-
docsgpt/agents/tools/wiki.py | 38 +
.../versions/0038_resource_access_settings.py | 65 ++
.../api/answer/services/stream_processor.py | 62 +-
docsgpt/api/connector/routes.py | 105 ++-
docsgpt/api/pat/rules.py | 2 +
docsgpt/api/user/agents/folders.py | 25 +-
docsgpt/api/user/agents/guardrails.py | 63 +-
docsgpt/api/user/agents/portability.py | 16 +-
docsgpt/api/user/agents/routes.py | 297 +++++--
docsgpt/api/user/agents/sharing.py | 56 +-
docsgpt/api/user/agents/webhooks.py | 12 +-
docsgpt/api/user/analytics/routes.py | 53 +-
docsgpt/api/user/prompts/routes.py | 126 +--
docsgpt/api/user/resource_access.py | 334 ++++++++
docsgpt/api/user/scheduler_worker.py | 43 ++
docsgpt/api/user/schedules/routes.py | 168 +++-
docsgpt/api/user/sources/access.py | 53 ++
docsgpt/api/user/sources/chunks.py | 75 +-
docsgpt/api/user/sources/retrieval_test.py | 16 +-
docsgpt/api/user/sources/routes.py | 319 ++++----
docsgpt/api/user/sources/upload.py | 23 +-
docsgpt/api/user/team_sharing.py | 31 +-
docsgpt/api/user/teams/routes.py | 353 ++++++++-
docsgpt/api/user/tools/mcp.py | 247 +++---
docsgpt/api/user/tools/routes.py | 444 ++++++++---
docsgpt/api/user/workflows/routes.py | 103 ++-
docsgpt/storage/db/models.py | 33 +
.../db/repositories/team_resource_grants.py | 5 +-
.../db/repositories/user_tool_preferences.py | 91 +++
frontend/DESIGN.md | 5 +-
frontend/src/Navigation.tsx | 8 +-
frontend/src/agents/AgentCard.test.tsx | 266 +++++++
frontend/src/agents/AgentCard.tsx | 155 ++--
frontend/src/agents/AgentPageHeader.test.tsx | 25 +
frontend/src/agents/AgentPageHeader.tsx | 16 +-
frontend/src/agents/AgentRouteGuard.test.tsx | 128 +++
frontend/src/agents/AgentRouteGuard.tsx | 83 ++
frontend/src/agents/NewAgent.test.tsx | 199 ++++-
frontend/src/agents/NewAgent.tsx | 154 ++--
frontend/src/agents/agentAccess.test.ts | 54 ++
frontend/src/agents/agentAccess.ts | 51 ++
frontend/src/agents/index.tsx | 36 +-
frontend/src/agents/types/index.ts | 5 +
.../src/agents/workflow/WorkflowBuilder.tsx | 70 +-
.../src/agents/workflow/workflowHelpers.ts | 5 +
frontend/src/api/endpoints.ts | 1 +
.../src/api/services/teamsService.test.ts | 81 ++
frontend/src/api/services/teamsService.ts | 91 +++
frontend/src/components/Chunks.test.tsx | 35 +
frontend/src/components/Chunks.tsx | 64 +-
.../src/components/ConnectorTree.test.tsx | 95 ++-
frontend/src/components/ConnectorTree.tsx | 53 +-
frontend/src/components/FileTree.tsx | 11 +-
frontend/src/components/GraphView.tsx | 4 +
frontend/src/components/MessageInput.tsx | 48 +-
.../src/components/graph/GraphChunkSheet.tsx | 61 +-
.../src/components/graph/GraphEntities.tsx | 4 +
.../src/components/graph/GraphNodePanel.tsx | 7 +
.../components/graph/GraphSourceView.test.tsx | 84 +-
.../src/components/graph/GraphSourceView.tsx | 11 +
.../src/components/tree/TreeBrowser.test.tsx | 11 +
frontend/src/components/tree/TreeBrowser.tsx | 7 +
frontend/src/components/ui/list-row.test.tsx | 11 +
frontend/src/components/ui/list-row.tsx | 23 +-
frontend/src/conversation/Conversation.tsx | 6 +-
frontend/src/locale/de.json | 228 +++++-
frontend/src/locale/en.json | 228 +++++-
frontend/src/locale/es.json | 228 +++++-
frontend/src/locale/jp.json | 228 +++++-
frontend/src/locale/ru.json | 228 +++++-
frontend/src/locale/zh-TW.json | 228 +++++-
frontend/src/locale/zh.json | 228 +++++-
.../src/modals/AgentDetailsModal.test.tsx | 118 +++
frontend/src/modals/AgentDetailsModal.tsx | 126 ++-
frontend/src/modals/MCPServerModal.test.tsx | 200 +++++
frontend/src/modals/MCPServerModal.tsx | 137 +++-
frontend/src/models/misc.ts | 12 +-
frontend/src/navigation/sections.test.ts | 42 +
frontend/src/navigation/sections.ts | 66 +-
frontend/src/navigation/useSectionContext.ts | 11 +-
frontend/src/navigation/useSectionResolver.ts | 11 +-
.../src/preferences/PromptsModal.test.tsx | 36 +
frontend/src/preferences/PromptsModal.tsx | 13 +-
frontend/src/settings/Prompts.test.tsx | 228 +++++-
frontend/src/settings/Prompts.tsx | 109 ++-
.../src/settings/SourceConfigModal.test.tsx | 105 +++
frontend/src/settings/SourceConfigModal.tsx | 8 +-
frontend/src/settings/Sources.test.tsx | 318 ++++++++
frontend/src/settings/Sources.tsx | 124 +--
frontend/src/settings/Teams.test.tsx | 358 +++++++++
frontend/src/settings/Teams.tsx | 730 ++++++++++++++++--
frontend/src/settings/ToolConfig.test.tsx | 140 ++++
frontend/src/settings/ToolConfig.tsx | 137 ++--
frontend/src/settings/Tools.test.tsx | 266 +++++++
frontend/src/settings/Tools.tsx | 128 ++-
frontend/src/settings/index.test.tsx | 138 ++++
frontend/src/settings/index.tsx | 44 +-
frontend/src/settings/types/index.ts | 18 +
frontend/src/teams/ShareToTeamModal.test.tsx | 324 ++++++++
frontend/src/teams/ShareToTeamModal.tsx | 686 +++++++++++-----
frontend/src/teams/accessSettings.ts | 164 ++++
frontend/src/teams/teamsSlice.test.ts | 65 ++
frontend/src/teams/teamsSlice.ts | 14 +-
frontend/src/utils/accessUtils.test.ts | 54 ++
frontend/src/utils/accessUtils.ts | 76 ++
frontend/src/utils/toolUtils.test.ts | 92 ++-
frontend/src/utils/toolUtils.ts | 31 +
tests/agents/test_default_tools.py | 5 +-
tests/agents/test_headless_runner_prompt.py | 76 ++
tests/agents/test_tool_access_runtime.py | 155 ++++
tests/agents/test_tool_executor_headless.py | 5 +
tests/agents/tools/test_wiki.py | 77 +-
.../services/test_stream_processor_access.py | 118 +++
tests/api/test_agent_team_sharing.py | 37 +-
tests/api/test_connector_routes_happy.py | 22 +-
tests/api/test_data_plane_audit.py | 33 +-
tests/api/test_teams_endpoints.py | 26 +-
tests/api/user/agents/test_roles_access.py | 703 +++++++++++++++++
tests/api/user/sources/test_chunks.py | 49 +-
.../sources/test_paginated_team_sharing.py | 5 +
tests/api/user/sources/test_routes.py | 16 +-
tests/api/user/sources/test_source_roles.py | 509 ++++++++++++
tests/api/user/teams/__init__.py | 0
.../api/user/teams/test_team_roles_routes.py | 531 +++++++++++++
tests/api/user/test_prompts.py | 4 +-
tests/api/user/test_prompts_access.py | 145 ++++
tests/api/user/test_resource_access.py | 205 +++++
tests/api/user/test_scheduler_worker.py | 64 ++
tests/api/user/test_schedules_routes.py | 2 +-
tests/api/user/test_tools_access.py | 571 ++++++++++++++
.../user/workflows/test_routes_coverage.py | 8 +-
134 files changed, 13725 insertions(+), 1702 deletions(-)
create mode 100644 docsgpt/alembic/versions/0038_resource_access_settings.py
create mode 100644 docsgpt/api/user/resource_access.py
create mode 100644 docsgpt/api/user/sources/access.py
create mode 100644 docsgpt/storage/db/repositories/user_tool_preferences.py
create mode 100644 frontend/src/agents/AgentCard.test.tsx
create mode 100644 frontend/src/agents/AgentRouteGuard.test.tsx
create mode 100644 frontend/src/agents/AgentRouteGuard.tsx
create mode 100644 frontend/src/agents/agentAccess.test.ts
create mode 100644 frontend/src/agents/agentAccess.ts
create mode 100644 frontend/src/api/services/teamsService.test.ts
create mode 100644 frontend/src/modals/AgentDetailsModal.test.tsx
create mode 100644 frontend/src/modals/MCPServerModal.test.tsx
create mode 100644 frontend/src/settings/SourceConfigModal.test.tsx
create mode 100644 frontend/src/settings/Sources.test.tsx
create mode 100644 frontend/src/settings/Teams.test.tsx
create mode 100644 frontend/src/settings/Tools.test.tsx
create mode 100644 frontend/src/settings/index.test.tsx
create mode 100644 frontend/src/teams/ShareToTeamModal.test.tsx
create mode 100644 frontend/src/teams/accessSettings.ts
create mode 100644 frontend/src/teams/teamsSlice.test.ts
create mode 100644 frontend/src/utils/accessUtils.test.ts
create mode 100644 frontend/src/utils/accessUtils.ts
create mode 100644 tests/agents/test_headless_runner_prompt.py
create mode 100644 tests/agents/test_tool_access_runtime.py
create mode 100644 tests/api/answer/services/test_stream_processor_access.py
create mode 100644 tests/api/user/agents/test_roles_access.py
create mode 100644 tests/api/user/sources/test_source_roles.py
create mode 100644 tests/api/user/teams/__init__.py
create mode 100644 tests/api/user/teams/test_team_roles_routes.py
create mode 100644 tests/api/user/test_prompts_access.py
create mode 100644 tests/api/user/test_resource_access.py
create mode 100644 tests/api/user/test_tools_access.py
diff --git a/docsgpt/agents/default_tools.py b/docsgpt/agents/default_tools.py
index f3bd1a09..187c57aa 100644
--- a/docsgpt/agents/default_tools.py
+++ b/docsgpt/agents/default_tools.py
@@ -369,6 +369,11 @@ def resolve_tool_by_id(
Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved
row so callers can branch on either path without losing the discriminator.
+
+ A ``user_tools`` row resolves when ``user`` owns it or a team grant gives
+ ``user`` the ``use_in_own`` action on it (checked live, so a revoked grant
+ drops the tool on the next run). The returned row is the owner's, so its
+ ``user_id`` is the credential owner.
"""
default_name = default_tool_name_for_id(tool_id)
builtin_name = builtin_agent_tool_name_for_id(tool_id)
@@ -382,4 +387,24 @@ def resolve_tool_by_id(
return synthesize_builtin_agent_tool(builtin_name)
if user_tools_repo is None or not user:
return None
- return user_tools_repo.get_any(str(tool_id), user)
+ row = user_tools_repo.get_any(str(tool_id), user)
+ if row is not None:
+ return row
+ return _resolve_shared_tool(str(tool_id), user, user_tools_repo)
+
+
+def _resolve_shared_tool(tool_id: str, user: str, user_tools_repo: Any) -> Optional[Dict[str, Any]]:
+ """The owner's row for a team-shared tool ``user`` may use in their own agents."""
+ conn = getattr(user_tools_repo, "_conn", None)
+ if conn is None:
+ return None
+ # Lazy: resource_access lives under docsgpt.api, whose package import
+ # pulls in every route module (and those import this module).
+ from docsgpt.api.user.resource_access import resolve
+
+ ra = resolve(conn, "tool", tool_id, user)
+ if ra is None or ra.access == "owner" or not ra.can("use_in_own"):
+ if ra is not None:
+ logger.info("shared tool %s not usable by %s (access=%s); dropped", tool_id, user, ra.access)
+ return None
+ return user_tools_repo.get_any(ra.resource_id, ra.owner_id)
diff --git a/docsgpt/agents/headless_runner.py b/docsgpt/agents/headless_runner.py
index 0db09d55..5e1413d3 100644
--- a/docsgpt/agents/headless_runner.py
+++ b/docsgpt/agents/headless_runner.py
@@ -14,7 +14,10 @@ from docsgpt.api.answer.services.prompt_renderer import (
prompt_embeds_documents,
resolve_prompt_skeleton,
)
-from docsgpt.api.answer.services.stream_processor import get_prompt
+from docsgpt.api.answer.services.stream_processor import (
+ authorized_prompt_id,
+ get_prompt,
+)
from docsgpt.core.settings import settings
from docsgpt.quotas.service import QuotaExceededError, QuotaService
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -156,7 +159,9 @@ def _run_agent_headless(
# ``chunks=0`` switches retrieval off; only a missing value takes the default.
raw_chunks = agent_config.get("chunks")
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
- prompt_id = agent_config.get("prompt_id", "default")
+ # Runs as the owner: a prompt they can no longer use (revoked grant,
+ # deleted) falls back to the default instead of rendering anyway.
+ prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner)
user_api_key = agent_config.get("key")
agent_id = _resolve_agent_id(agent_config)
agent_type = agent_config.get("agent_type", "classic")
diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py
index bc7f809a..a8e043c4 100644
--- a/docsgpt/agents/tool_executor.py
+++ b/docsgpt/agents/tool_executor.py
@@ -1,3 +1,4 @@
+import copy
import logging
import re
import uuid
@@ -29,6 +30,45 @@ from docsgpt.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
+#: ``user_tools.config`` key holding an api_tool's encrypted header /
+#: query-param values: ``{action: {section: {param: value}}}``, keyed by the
+#: tool owner's id. The plaintext ``value`` of those entries is kept empty.
+API_TOOL_SECRETS_KEY = "encrypted_action_secrets"
+API_TOOL_SECRET_SECTIONS = ("headers", "query_params")
+
+
+def api_tool_action_with_secrets(tool_data: Dict, action_name: str, fallback_owner: Optional[str] = None) -> Dict:
+ """An api_tool action definition with its stored secret values merged back.
+
+ Secrets are decrypted with the tool row's ``user_id`` (the owner), never
+ the invoker's id. Legacy rows that still hold plaintext values need no
+ merge and are returned as stored.
+
+ Args:
+ tool_data: The ``user_tools`` row.
+ action_name: The action key in ``config["actions"]``.
+ fallback_owner: Used only when the row carries no ``user_id``.
+
+ Returns:
+ A deep copy of the action with header / query-param values filled in.
+ """
+ config = tool_data.get("config") or {}
+ action = copy.deepcopy(config["actions"][action_name])
+ blob = config.get(API_TOOL_SECRETS_KEY)
+ owner = tool_data.get("user_id") or fallback_owner
+ if not blob or not owner:
+ return action
+ secrets = decrypt_credentials(blob, owner).get(action_name) or {}
+ for section in API_TOOL_SECRET_SECTIONS:
+ block = action.get(section)
+ props = block.get("properties") if isinstance(block, dict) else None
+ if not isinstance(props, dict):
+ continue
+ for param, value in (secrets.get(section) or {}).items():
+ if isinstance(props.get(param), dict):
+ props[param]["value"] = value
+ return action
+
def record_tool_span_start(call: Any, **attributes: Any) -> Any:
"""Open an ``execute_tool`` span for ``call`` (no-op without an active trace)."""
@@ -565,6 +605,7 @@ class ToolExecutor:
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
with db_readonly() as conn:
user_tools = UserToolsRepository(conn).list_active_for_user(user)
+ user_tools.extend(self._shared_in_chat_tools(conn, user))
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
# Headless agentless runs (e.g. scheduled fire) drop chat-only
# tools (``scheduler``) from explicit user_tools too.
@@ -581,6 +622,32 @@ class ToolExecutor:
tools[str(default_row["id"])] = default_row
return tools
+ @staticmethod
+ def _shared_in_chat_tools(conn, user: str) -> List[Dict]:
+ """Team-shared tools the user switched into their chats and may still use.
+
+ The grant (and the ``use_in_own`` switch) is re-checked on every call;
+ a revoked tool is dropped silently. Rows are the owner's, so their
+ credentials decrypt with the owner's id.
+ """
+ from docsgpt.storage.db.repositories.user_tool_preferences import (
+ UserToolPreferencesRepository,
+ )
+
+ tool_ids = UserToolPreferencesRepository(conn).list_in_chat_tool_ids(user)
+ if not tool_ids:
+ return []
+ repo = UserToolsRepository(conn)
+ rows: List[Dict] = []
+ for tid in tool_ids:
+ row = resolve_tool_by_id(tid, user, user_tools_repo=repo)
+ if row is None or row.get("user_id") == user:
+ if row is None:
+ logger.info("in-chat shared tool %s no longer usable by %s; dropped", tid, user)
+ continue
+ rows.append(row)
+ return rows
+
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
"""Merge client-provided tool definitions into tools_dict.
@@ -1261,7 +1328,7 @@ class ToolExecutor:
return error_message, call_id
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
action_data = (
- tool_data["config"]["actions"][action_name]
+ api_tool_action_with_secrets(tool_data, action_name, self.user)
if tool_data["name"] == "api_tool"
else next(action for action in tool_data["actions"] if action["name"] == action_name)
)
@@ -1528,10 +1595,13 @@ class ToolExecutor:
if tool_data["name"] == "mcp_tool":
tool_config["query_mode"] = True
+ # MCP OAuth tokens are looked up by user id: a shared server runs on
+ # the owner's connection, like every other credential.
+ load_user = (tool_data.get("user_id") or self.user) if tool_data["name"] == "mcp_tool" else self.user
tool = tm.load_tool(
tool_data["name"],
tool_config=tool_config,
- user_id=self.user,
+ user_id=load_user,
)
# Don't cache api_tool since config varies by action
diff --git a/docsgpt/agents/tools/wiki.py b/docsgpt/agents/tools/wiki.py
index 65003033..372532f8 100644
--- a/docsgpt/agents/tools/wiki.py
+++ b/docsgpt/agents/tools/wiki.py
@@ -19,6 +19,8 @@ WIKI_UPDATED_VIA_AGENT = "agent"
MAX_WIKI_PAGE_BYTES = 1_000_000
+_WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"})
+
class WikiTool(Tool):
"""Wiki
@@ -49,6 +51,11 @@ class WikiTool(Tool):
if not self.source_id:
return "Error: WikiTool requires a source_id."
+ if action_name in _WRITE_ACTIONS:
+ denied = self._write_denied()
+ if denied:
+ return denied
+
if action_name == "view":
return self._view(kwargs.get("path", "/"), kwargs.get("view_range"))
if action_name == "create":
@@ -192,6 +199,37 @@ class WikiTool(Tool):
},
]
+ def _write_denied(self) -> Optional[str]:
+ """Re-check the caller's live ``edit`` right before a write.
+
+ The tool is attached for a writable source when the conversation
+ starts, but a grant can be revoked (or downgraded to viewer) mid-run.
+ Resolving access on every write stops the agent from editing once the
+ caller no longer may. Fails closed on an unknown caller or a failed
+ lookup. Re-embeds still run as the owner.
+
+ Returns:
+ Optional[str]: An error message for the LLM, or None when allowed.
+ """
+ from docsgpt.api.user import resource_access
+
+ message = "Error: You no longer have edit access to this wiki, so it can't be changed."
+ if not self.updated_by:
+ return message
+ try:
+ with db_readonly() as conn:
+ access = resource_access.resolve(
+ conn, "source", str(self.source_id), self.updated_by
+ )
+ except Exception:
+ logger.exception(
+ "Wiki write access check failed for source %s", self.source_id
+ )
+ return message
+ if access is None or not access.can("edit"):
+ return message
+ return None
+
def get_config_requirements(self) -> Dict[str, Any]:
return {}
diff --git a/docsgpt/alembic/versions/0038_resource_access_settings.py b/docsgpt/alembic/versions/0038_resource_access_settings.py
new file mode 100644
index 00000000..5c6f68d0
--- /dev/null
+++ b/docsgpt/alembic/versions/0038_resource_access_settings.py
@@ -0,0 +1,65 @@
+"""0038 resource access settings — per-asset sharing switches and tool chat prefs.
+
+``resource_share_settings`` holds the owner's per-asset switches that adjust
+what the fixed roles may do on one shared resource ("Editors can share",
+"Viewers can see logs", ...). One row per ``(resource_type, resource_id)``;
+a missing row means every switch is at its default. The table is polymorphic
+like ``team_resource_grants``, so it has no FK and the switch keys are
+validated in code (``docsgpt/api/user/resource_access.py``), which keeps new
+switches migration-free.
+
+``user_tool_preferences`` is the personal "In my chats" switch for a tool
+shared with the caller. A grantee can't write the owner's ``user_tools.status``
+(that is the owner's own chat setting), so each grantee gets a row here.
+A missing row means off: sharing a tool never adds it to anyone's chats.
+
+Idempotent both ways.
+
+Revision ID: 0038_resource_access_settings
+Revises: 0037_request_traces
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0038_resource_access_settings"
+down_revision: Union[str, None] = "0037_request_traces"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS resource_share_settings (
+ resource_type TEXT NOT NULL
+ CONSTRAINT resource_share_settings_type_check
+ CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')),
+ resource_id UUID NOT NULL,
+ settings JSONB NOT NULL DEFAULT '{}'::jsonb,
+ updated_by TEXT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (resource_type, resource_id)
+ );
+ """
+ )
+ op.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_tool_preferences (
+ user_id TEXT NOT NULL,
+ tool_id UUID NOT NULL REFERENCES user_tools(id) ON DELETE CASCADE,
+ in_chat BOOLEAN NOT NULL DEFAULT false,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, tool_id)
+ );
+ CREATE INDEX IF NOT EXISTS user_tool_preferences_tool_idx
+ ON user_tool_preferences (tool_id);
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS user_tool_preferences;")
+ op.execute("DROP TABLE IF EXISTS resource_share_settings;")
diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py
index 24688aa7..c72b1055 100644
--- a/docsgpt/api/answer/services/stream_processor.py
+++ b/docsgpt/api/answer/services/stream_processor.py
@@ -109,6 +109,50 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str:
raise ValueError(f"Invalid prompt ID: {prompt_id}") from e
+_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",)
+
+
+def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
+ """``prompt_id`` if ``principal`` may use it, else ``"default"``.
+
+ Presets pass through. A custom prompt must be owned by ``principal`` or
+ reach them through a team grant with ``use`` (checked live); a revoked,
+ deleted or foreign prompt falls back to the default prompt.
+
+ Args:
+ prompt_id: The configured prompt (preset name, UUID or legacy id).
+ principal: The agent owner for an agent run, else the caller.
+
+ Returns:
+ The prompt id to render.
+ """
+ if prompt_id is None or prompt_id == "":
+ return prompt_id
+ pid = str(prompt_id)
+ if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
+ return prompt_id
+ from docsgpt.api.user.resource_access import resolve
+
+ try:
+ with db_readonly() as conn:
+ ra = resolve(conn, "prompt", pid, principal) if principal else None
+ except Exception:
+ logger.exception("Prompt access check failed for %s", pid)
+ ra = None
+ if ra is not None and ra.can("use"):
+ return prompt_id
+ logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
+ return "default"
+
+
+def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]:
+ """The owner id to write a wiki source as, when ``caller`` may edit it."""
+ from docsgpt.api.user.resource_access import resolve
+
+ ra = resolve(conn, "source", source_id, caller)
+ return ra.owner_id if ra is not None and ra.can("edit") else None
+
+
T = TypeVar("T")
@@ -934,7 +978,12 @@ class StreamProcessor:
self.agent_config.update(
{
- "prompt_id": self._agent_data.get("prompt_id", "default"),
+ # The agent runs in its owner's context: its prompt must
+ # be one the owner may use (re-checked on every run).
+ "prompt_id": authorized_prompt_id(
+ self._agent_data.get("prompt_id", "default"),
+ self._agent_data.get("user"),
+ ),
"agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME),
"user_api_key": effective_key,
"json_schema": self._agent_data.get("json_schema"),
@@ -998,9 +1047,10 @@ class StreamProcessor:
if preview_workflow_id:
self.agent_config["workflow_id"] = str(preview_workflow_id)
+ caller = self.decoded_token.get("sub") if isinstance(self.decoded_token, dict) else None
self.agent_config.update(
{
- "prompt_id": self.data.get("prompt_id", "default"),
+ "prompt_id": authorized_prompt_id(self.data.get("prompt_id", "default"), caller),
"agent_type": agent_type,
"user_api_key": None,
"json_schema": None,
@@ -1134,14 +1184,12 @@ class StreamProcessor:
"""Resolve the WikiTool config for the first writable wiki source.
A source qualifies when ``SourceConfig.parse(config).kind == "wiki"`` and
- the principal can write it (``effective_write_owner`` returns an owner —
- owner or team editor; viewers get None and no tool). v1 supports one
+ the principal may ``edit`` it (owner or team editor; viewers get no
+ tool) — resolved live through ``resource_access``. v1 supports one
writable wiki source; the first match wins and the scan stops there so
this runs at most one owner+source lookup per chat on the hot path.
Returns None when no writable wiki source is present.
"""
- from docsgpt.api.user.team_sharing import effective_write_owner
-
caller = self.decoded_token.get("sub") if self.decoded_token else None
if not caller:
return None
@@ -1155,7 +1203,7 @@ class StreamProcessor:
if not sid or sid == "default":
continue
sid = str(sid)
- owner = effective_write_owner(conn, "source", sid, caller)
+ owner = _wiki_write_owner(conn, sid, caller)
if not owner:
continue
source_doc = repo.get_any(sid, owner)
diff --git a/docsgpt/api/connector/routes.py b/docsgpt/api/connector/routes.py
index 5240ee58..dccdda16 100644
--- a/docsgpt/api/connector/routes.py
+++ b/docsgpt/api/connector/routes.py
@@ -17,6 +17,8 @@ from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
+from docsgpt.api.user.resource_access import AccessDenied
+from docsgpt.api.user.sources.access import load_source
from docsgpt.api.user.tasks import (
ingest_connector_task,
)
@@ -26,7 +28,6 @@ from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
owns_connector_session,
)
-from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.session import db_readonly, db_session
@@ -499,6 +500,40 @@ class ConnectorDisconnect(Resource):
return make_response(jsonify({"success": False, "error": "Failed to disconnect session"}), 500)
+def _owner_connector_session(conn, owner_id: str, provider: str) -> Optional[dict]:
+ """The owner's usable connector session for ``provider``, or None.
+
+ Used when a team editor syncs a shared connector source: the sync runs
+ with the owner's account. A session with no token, no stored credentials,
+ or an expired access token that can't be refreshed counts as missing.
+
+ Args:
+ conn: Open database connection.
+ owner_id: The source owner's ``sub``.
+ provider: The source's connector provider.
+
+ Returns:
+ Optional[dict]: The session row, or None when the owner must reconnect.
+ """
+ candidates = [
+ s for s in ConnectorSessionsRepository(conn).list_for_user(owner_id)
+ if owns_connector_session(s, owner_id, provider)
+ and s.get("session_token") and s.get("token_info")
+ ]
+ if not candidates:
+ return None
+ session = candidates[0]
+ token_info = session["token_info"]
+ if not token_info.get("refresh_token"):
+ try:
+ if ConnectorCreator.create_auth(provider).is_token_expired(token_info):
+ return None
+ except Exception:
+ # Providers without an expiry check leave the verdict to the sync.
+ pass
+ return session
+
+
@connectors_ns.route("/api/connectors/sync")
class ConnectorSync(Resource):
@api.expect(
@@ -506,7 +541,11 @@ class ConnectorSync(Resource):
"ConnectorSyncModel",
{
"source_id": fields.String(required=True, description="Source ID to sync"),
- "session_token": fields.String(required=True, description="Authentication token")
+ "session_token": fields.String(
+ required=False,
+ description="The owner's connector session token (ignored for team editors, "
+ "whose sync uses the owner's session)",
+ )
},
)
)
@@ -517,33 +556,41 @@ class ConnectorSync(Resource):
return make_response(jsonify({"success": False}), 401)
try:
- data = request.get_json()
+ data = request.get_json() or {}
source_id = data.get('source_id')
session_token = data.get('session_token')
- if not all([source_id, session_token]):
+ if not source_id:
return make_response(
jsonify({
"success": False,
"error": "source_id and session_token are required"
- }),
+ }),
400
)
user_id = decoded_token.get('sub')
- with db_readonly() as conn:
- source = SourcesRepository(conn).get_any(source_id, user_id)
- if not source:
+ # Owner or team editor. The sync always runs AS the owner, with the
+ # owner's connector account: a grantee can't point the source at
+ # their own account (that is ``reconnect``, owner-only).
+ try:
+ with db_readonly() as conn:
+ source, ra = load_source(conn, source_id, user_id, "edit")
+ except AccessDenied as err:
+ return make_response(
+ jsonify({"success": False, "error": err.message, "message": err.message}),
+ err.status,
+ )
+ owner_id = ra.owner_id
+ is_owner = ra.access == "owner"
+ if is_owner and not session_token:
return make_response(
jsonify({
"success": False,
- "error": "Source not found"
+ "error": "source_id and session_token are required"
}),
- 404
+ 400
)
- # ``get_any`` already scopes by ``user_id``; an extra guard
- # here would be dead code.
-
remote_data = source.get('remote_data') or {}
if isinstance(remote_data, str):
try:
@@ -558,17 +605,31 @@ class ConnectorSync(Resource):
jsonify({
"success": False,
"error": "Source provider not found in remote_data"
- }),
+ }),
400
)
- with db_readonly() as conn:
- session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
- if not owns_connector_session(session, user_id, source_type):
- return make_response(
- jsonify({"success": False, "error": "Invalid or unauthorized session"}),
- 401,
- )
+ if is_owner:
+ with db_readonly() as conn:
+ session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
+ if not owns_connector_session(session, user_id, source_type):
+ return make_response(
+ jsonify({"success": False, "error": "Invalid or unauthorized session"}),
+ 401,
+ )
+ else:
+ with db_readonly() as conn:
+ session = _owner_connector_session(conn, owner_id, source_type)
+ if session is None:
+ message = (
+ "The owner needs to reconnect this source's account "
+ "before it can be synced."
+ )
+ return make_response(
+ jsonify({"success": False, "error": message, "message": message}),
+ 409,
+ )
+ session_token = session["session_token"]
# Extract configuration from remote_data
file_ids = remote_data.get('file_ids', [])
@@ -578,7 +639,7 @@ class ConnectorSync(Resource):
# Start the sync task
task = ingest_connector_task.delay(
job_name=source.get('name'),
- user=decoded_token.get('sub'),
+ user=owner_id,
source_type=source_type,
session_token=session_token,
file_ids=file_ids,
diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py
index cea33e79..80223f81 100644
--- a/docsgpt/api/pat/rules.py
+++ b/docsgpt/api/pat/rules.py
@@ -309,6 +309,7 @@ RULES: dict[tuple[str, str], Rule] = {
("/api/teams//members", "GET"): _rule("teams:read"),
("/api/teams//grants", "GET"): _rule("teams:read"),
("/api/resource_shares", "GET"): _rule("teams:read"),
+ ("/api/resource_settings", "GET"): _rule("teams:read"),
# Chat
("/api/answer", "POST"): _rule("chat:run", **_CHAT),
("/stream", "POST"): _rule("chat:run", **_CHAT),
@@ -355,6 +356,7 @@ DENIED: dict[str, tuple[str, ...]] = {
"/api/teams//members/": ("*",),
"/api/teams//grants": ("POST", "DELETE"),
"/api/teams//transfer_owner": ("*",),
+ "/api/resource_settings": ("PUT",),
"/swagger.json": ("*",),
}
DENIED_PREFIXES = (
diff --git a/docsgpt/api/user/agents/folders.py b/docsgpt/api/user/agents/folders.py
index f4653a5b..e70b9212 100644
--- a/docsgpt/api/user/agents/folders.py
+++ b/docsgpt/api/user/agents/folders.py
@@ -8,6 +8,7 @@ from flask_restx import Namespace, Resource, fields
from sqlalchemy import text as _sql_text
from docsgpt.api import api
+from docsgpt.api.user.resource_access import AccessDenied, payload_for, require, settings_many
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -143,11 +144,16 @@ class AgentFolder(Resource):
),
{"user_id": user, "fid": pg_folder_id},
).fetchall()
+ switches = settings_many(
+ conn, "agent", [str(row._mapping["id"]) for row in agents_rows]
+ )
+ # Folder contents are the caller's own agents.
agents_list = [
{
"id": str(row._mapping["id"]),
"name": row._mapping["name"],
"description": row._mapping.get("description", "") or "",
+ **payload_for("agent", "owner", switches[str(row._mapping["id"])]),
}
for row in agents_rows
]
@@ -298,7 +304,14 @@ class MoveAgentToFolder(Resource):
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
- agent = agents_repo.get_any(agent_id_input, user)
+ # Folders are the owner's own organisation of their agents.
+ try:
+ ra = require(conn, "agent", agent_id_input, user, "move_folder")
+ except AccessDenied as denied:
+ return make_response(
+ jsonify({"success": False, "message": denied.message}), denied.status
+ )
+ agent = agents_repo.get_by_id(ra.resource_id)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}),
@@ -357,10 +370,18 @@ class BulkMoveAgents(Resource):
404,
)
pg_folder_id = str(folder["id"])
+ # Only the caller's own agents move (``move_folder`` is
+ # owner-only); anything else is reported back, not dropped.
+ moved, skipped = [], []
for agent_id_input in agent_ids:
agent = agents_repo.get_any(agent_id_input, user)
if agent is not None:
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
- return make_response(jsonify({"success": True}), 200)
+ moved.append(str(agent_id_input))
+ else:
+ skipped.append(str(agent_id_input))
+ return make_response(
+ jsonify({"success": True, "moved": moved, "skipped": skipped}), 200
+ )
except Exception as err:
return _folder_error_response("Failed to move agents", err)
diff --git a/docsgpt/api/user/agents/guardrails.py b/docsgpt/api/user/agents/guardrails.py
index 2d0a0635..46bd8417 100644
--- a/docsgpt/api/user/agents/guardrails.py
+++ b/docsgpt/api/user/agents/guardrails.py
@@ -4,7 +4,7 @@ from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
from docsgpt.api import api
-from docsgpt.api.user.team_sharing import team_access_for
+from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
from docsgpt.core.settings import settings
from docsgpt.guardrails.checks.patterns import DEFAULT_PII_ENTITIES, PII_PATTERNS
from docsgpt.guardrails.config import DEFAULT_BLOCK_MESSAGE, MODES
@@ -70,15 +70,30 @@ class GuardrailCatalog(Resource):
)
-def _readable_agent(conn, agent_id: str, user: str):
- """Return the agent row when the caller may read it, else None."""
- repo = AgentsRepository(conn)
- agent = repo.get_any(agent_id, user)
- if agent:
- return agent
- if team_access_for(conn, user, "agent", agent_id):
- return repo.get_by_id(agent_id)
- return None
+def _logs_access(conn, agent_id: str, user: str) -> tuple[dict, ResourceAccess]:
+ """The agent row and the caller's access, for reading its guardrail journal.
+
+ Args:
+ conn: Open database connection.
+ agent_id: The agent's id (UUID or legacy).
+ user: The caller.
+
+ Returns:
+ ``(agent, access)``; rows are read as ``access.owner_id``, so a team
+ member with ``view_logs`` sees exactly what the owner sees.
+
+ Raises:
+ AccessDenied: 404 when the agent isn't visible, 403 without ``view_logs``.
+ """
+ ra = require(conn, "agent", agent_id, user, "view_logs")
+ agent = AgentsRepository(conn).get_by_id(ra.resource_id)
+ if agent is None:
+ raise AccessDenied(404, "Agent not found")
+ return agent, ra
+
+
+def _denied(err: AccessDenied):
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
@agents_guardrails_ns.route("/guardrails/events")
@@ -106,17 +121,16 @@ class GuardrailEvents(Resource):
400,
)
with db_readonly() as conn:
- agent = _readable_agent(conn, agent_id, user)
- if not agent:
- return make_response(
- jsonify({"success": False, "message": "Agent not found"}), 404
- )
+ try:
+ agent, ra = _logs_access(conn, agent_id, user)
+ except AccessDenied as denied:
+ return _denied(denied)
# Query on the row's UUID, not the caller's argument: a legacy
# 24-hex Mongo id resolves fine above but would blow up the cast.
- # Rows stay scoped to the requesting user even on a shared agent —
- # another member's blocked prompts are not this caller's to read.
+ # Rows are the owner's view: ``view_logs`` shows a team member
+ # what the owner sees, never other members' own chats.
events = GuardrailEventsRepository(conn).list_for_agent(
- str(agent["id"]), user, limit=limit, offset=offset
+ str(agent["id"]), ra.owner_id, limit=limit, offset=offset
)
return make_response(jsonify({"success": True, "events": events}), 200)
@@ -143,14 +157,15 @@ class GuardrailSummary(Resource):
agent_id = request.args.get("agent_id")
with db_readonly() as conn:
scoped_id = None
+ scope_user = user
if agent_id:
- agent = _readable_agent(conn, agent_id, user)
- if not agent:
- return make_response(
- jsonify({"success": False, "message": "Agent not found"}), 404
- )
+ try:
+ agent, ra = _logs_access(conn, agent_id, user)
+ except AccessDenied as denied:
+ return _denied(denied)
scoped_id = str(agent["id"])
+ scope_user = ra.owner_id
summary = GuardrailEventsRepository(conn).summary_for_user(
- user, days=days, agent_id=scoped_id
+ scope_user, days=days, agent_id=scoped_id
)
return make_response(jsonify({"success": True, **summary}), 200)
diff --git a/docsgpt/api/user/agents/portability.py b/docsgpt/api/user/agents/portability.py
index ca0e0b65..289f2512 100644
--- a/docsgpt/api/user/agents/portability.py
+++ b/docsgpt/api/user/agents/portability.py
@@ -37,6 +37,7 @@ from docsgpt.agents.default_tools import (
)
from docsgpt.api import api
from docsgpt.api.pat.rules import allowed_ids
+from docsgpt.api.user.resource_access import AccessDenied, require
from docsgpt.core.model_utils import validate_model_id
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url
@@ -1756,14 +1757,23 @@ class ExportAgent(Resource):
return make_response(jsonify({"success": False, "message": "id is required"}), 400)
with db_session() as conn:
repo = AgentsRepository(conn)
- agent = repo.get_any(agent_id, user)
+ try:
+ ra = require(conn, "agent", agent_id, user, "export")
+ except AccessDenied as denied:
+ return make_response(
+ jsonify({"success": False, "message": denied.message}), denied.status
+ )
+ agent = repo.get_by_id(ra.resource_id)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
- agent["slug"] = ensure_agent_slug(conn, agent, user)
+ # Serialized as the owner: the agent's prompt, sources, tools and
+ # workflow are the owner's (secrets are never exported).
+ owner_id = ra.owner_id
+ agent["slug"] = ensure_agent_slug(conn, agent, owner_id)
try:
- export = serialize_agent(conn, agent, user)
+ export = serialize_agent(conn, agent, owner_id)
except AgentExportError as exc:
return make_response(
jsonify({"success": False, "message": str(exc)}), 400
diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py
index e1cf6f1e..6b9daf45 100644
--- a/docsgpt/api/user/agents/routes.py
+++ b/docsgpt/api/user/agents/routes.py
@@ -26,9 +26,16 @@ from docsgpt.core.json_schema_utils import (
)
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ delete_settings,
+ payload_for,
+ require,
+ resolve,
+ settings_many,
+)
from docsgpt.api.user.team_sharing import (
can_access,
- team_access_for,
visible_with_access,
)
from docsgpt.agents.default_tools import is_synthesized_tool_id
@@ -196,6 +203,83 @@ def _resolve_folder_id(conn, folder_id, user):
return str(folder["id"]), None
+# What a caller who reached an agent only through its public share link may
+# do: chat with it and pin it. A team grant, when there is one, wins.
+LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
+
+# Agent fields that are the owner's policy (guardrails and the pooled quota).
+POLICY_FIELDS = (
+ "config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode",
+)
+
+
+def _denied(err: AccessDenied):
+ """The JSON error response for an :class:`AccessDenied`."""
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
+
+
+def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
+ """Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
+
+ Builtin synthetic ids belong to no one. Otherwise the tool must be the
+ agent owner's (it runs with the owner's credentials) or reach the caller
+ with ``use_in_own``.
+
+ Args:
+ conn: Open database connection.
+ tool_id: The tool id being attached.
+ owner_id: The agent's owner.
+ caller: The user making the change.
+
+ Returns:
+ True when the attachment is allowed.
+ """
+ tid = str(tool_id)
+ if is_synthesized_tool_id(tid):
+ return True
+ if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
+ return True
+ ra = resolve(conn, "tool", tid, caller)
+ return ra is not None and ra.can("use_in_own")
+
+
+def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
+ """Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
+
+ Args:
+ conn: Open database connection.
+ resource_type: ``source`` or ``prompt``.
+ resource_id: The referenced id.
+ owner_id: The agent's owner.
+ caller: The user making the change.
+
+ Returns:
+ True when the agent owner owns it or the caller can ``use`` it.
+ """
+ if not resource_id:
+ return True
+ if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
+ return True
+ return can_access(conn, resource_type, str(resource_id), caller)
+
+
+def _policy_changed(existing: dict, update_fields: dict) -> bool:
+ """True when ``update_fields`` changes any guardrail or quota value."""
+ for key in POLICY_FIELDS:
+ if key not in update_fields:
+ continue
+ new, old = update_fields[key], existing.get(key)
+ if key == "config":
+ if (new or {}) != (old or {}):
+ return True
+ elif key.startswith("limited_"):
+ if bool(new) != bool(old):
+ return True
+ elif int(new or 0) != int(old or 0):
+ return True
+ return False
+
+
def _reject(message: str, user: str, field: str = "-"):
"""Log a request-validation rejection at WARN and return its 400 response.
@@ -228,6 +312,7 @@ def _format_agent_output(
ownership: str = "user",
team_access: str | None = None,
resolve_names: bool = False,
+ access: dict | None = None,
) -> dict:
"""Shape a PG agent row into the outward API response dict.
@@ -239,7 +324,16 @@ def _format_agent_output(
``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for
ones shared with a team they're in; ``team_access`` (``viewer``/``editor``)
is set on team-shared agents so the UI can gate edit controls.
+
+ ``access`` is the caller's ``{"access", "allowed_actions"}`` payload
+ (owner with default switches when omitted). It is embedded verbatim and
+ decides what leaves the server: the full guardrail ``config`` needs
+ ``view``, the (masked) ``key`` and public ``shared_token`` need
+ ``manage_access_details``.
"""
+ if access is None:
+ access = payload_for("agent", "owner", {})
+ allowed = set(access.get("allowed_actions") or [])
source_id = agent.get("source_id")
extra_source_ids = agent.get("extra_source_ids") or []
source_value = str(source_id) if source_id else ""
@@ -288,7 +382,13 @@ def _format_agent_output(
),
"ownership": ownership,
"team_access": team_access,
+ "access": access.get("access"),
+ "allowed_actions": sorted(allowed),
}
+ # Guardrail policy is edit-page config; a chat-only caller doesn't need it
+ # (and reading the banned-term list makes evading it trivial).
+ if "view" not in allowed:
+ out["config"] = {}
# Resolve prompt/source NAMES by id (owner-agnostic) so a team member
# viewing a shared agent sees the owner's prompt + source names instead of
# a blank prompt / "External KB" (the client otherwise resolves these from
@@ -298,9 +398,9 @@ def _format_agent_output(
out["source_details"] = resolve_source_details(
([source_id] if source_id else []) + list(extra_source_ids)
)
- # Never expose the owner's share/API secrets to a team grantee — the
- # public ``shared_token`` and the (masked) agent ``key`` are owner-only.
- if ownership == "team":
+ # The public ``shared_token`` and the (masked) agent ``key`` are access
+ # details: only a caller who may manage them sees them.
+ if "manage_access_details" not in allowed:
out["shared_token"] = ""
return out
if include_key_masked:
@@ -435,23 +535,22 @@ class GetAgent(Resource):
return {"success": False, "message": "ID required"}, 400
try:
user = decoded_token["sub"]
- ownership, team_access = "user", None
+ agent = None
with db_readonly() as conn:
- repo = AgentsRepository(conn)
- agent = repo.get_any(agent_id, user)
- if not agent:
- # Team fallback: only after a grant check, fetch ownerless.
- team_access = team_access_for(conn, user, "agent", agent_id)
- if team_access:
- agent = repo.get_by_id(agent_id)
- ownership = "team"
+ # Anyone who can see the agent reads it (a viewer needs it to
+ # chat); what they get back is trimmed by their actions.
+ ra = resolve(conn, "agent", agent_id, user)
+ if ra is not None:
+ agent = AgentsRepository(conn).get_by_id(ra.resource_id)
if not agent:
return {"status": "Not found"}, 404
+ is_owner = ra.access == "owner"
data = _format_agent_output(
agent,
- ownership=ownership,
- team_access=team_access,
+ ownership="user" if is_owner else "team",
+ team_access=None if is_owner else ra.access,
resolve_names=True,
+ access=ra.payload(),
)
return make_response(jsonify(data), 200)
except Exception as e:
@@ -483,10 +582,18 @@ class GetAgents(Resource):
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
shared_agents = agents_repo.list_by_ids(shared_ids)
+ switches = settings_many(
+ conn, "agent", [str(a["id"]) for a in agents + shared_agents]
+ )
+
# Every agent is listed: one with no source skips retrieval and
# answers from the model and its tools, so it is still runnable.
list_agents = [
- _format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
+ _format_agent_output(
+ agent,
+ pinned=str(agent["id"]) in pinned_ids,
+ access=payload_for("agent", "owner", switches[str(agent["id"])]),
+ )
for agent in agents
]
list_agents += [
@@ -494,6 +601,11 @@ class GetAgents(Resource):
agent,
ownership="team",
team_access=team_shared.get(str(agent["id"])),
+ access=payload_for(
+ "agent",
+ team_shared.get(str(agent["id"])),
+ switches[str(agent["id"])],
+ ),
)
for agent in shared_agents
]
@@ -731,6 +843,14 @@ class CreateAgent(Resource):
jsonify({"success": False, "message": "Prompt not accessible"}),
403,
)
+ # Tools run with the agent owner's credentials: attach only your
+ # own, or ones a team lets you use in your agents.
+ for tid in data.get("tools") or []:
+ if not _tool_attachable(conn, tid, user, user):
+ return make_response(
+ jsonify({"success": False, "message": "Tool not accessible"}),
+ 403,
+ )
build_data = dict(data)
build_data["folder_id"] = pg_folder_id
@@ -888,23 +1008,15 @@ class UpdateAgent(Resource):
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
- is_team_editor = False
- existing_agent = agents_repo.get_any(agent_id, user)
- if not existing_agent:
- # Team write path: only an 'editor' grant may modify a
- # team-shared agent; a 'viewer' is read-only. Fetch the
- # ownerless row only AFTER confirming editor access.
- access = team_access_for(conn, user, "agent", agent_id)
- if access == "editor":
- existing_agent = agents_repo.get_by_id(agent_id)
- is_team_editor = True
- elif access == "viewer":
- return make_response(
- jsonify(
- {"success": False, "message": "Read-only: editor access required"}
- ),
- 403,
- )
+ try:
+ ra = require(conn, "agent", agent_id, user, "edit")
+ except AccessDenied as denied:
+ return _denied(denied)
+ # Every write lands as the owner; the row is fetched only after
+ # the access check above.
+ owner_id = ra.owner_id
+ is_team_editor = ra.access != "owner"
+ existing_agent = agents_repo.get_by_id(ra.resource_id)
if not existing_agent:
return make_response(
jsonify(
@@ -959,6 +1071,8 @@ class UpdateAgent(Resource):
user,
field,
)
+ if new_status != existing_agent.get("status") and not ra.can("publish"):
+ return _denied(AccessDenied(403, "Your access to this item doesn't allow that"))
update_fields["status"] = new_status
elif field == "source":
source_id = data.get("source")
@@ -1084,10 +1198,24 @@ class UpdateAgent(Resource):
field,
)
elif field == "folder_id":
- folder_input = data.get("folder_id")
+ # Folders are the owner's own organisation; re-sending
+ # the current folder is a no-op anyone may do.
+ folder_input = data.get("folder_id") or None
+ current_folder = (
+ str(existing_agent["folder_id"])
+ if existing_agent.get("folder_id")
+ else None
+ )
+ if folder_input == current_folder:
+ update_fields["folder_id"] = current_folder
+ continue
+ if not ra.can("move_folder"):
+ return _denied(
+ AccessDenied(403, "Only the owner can move this agent between folders")
+ )
if folder_input:
pg_folder_id, folder_err = _resolve_folder_id(
- conn, folder_input, user,
+ conn, folder_input, owner_id,
)
if folder_err:
return folder_err
@@ -1107,8 +1235,10 @@ class UpdateAgent(Resource):
return _reject("Workflow is required", user, field)
update_fields["workflow_id"] = None
else:
+ # The agent runs its workflow as the owner, so it
+ # must be one of the owner's workflows.
pg_workflow_id, wf_err = _resolve_workflow_for_user(
- conn, workflow_input, user,
+ conn, workflow_input, owner_id,
)
if wf_err:
return wf_err
@@ -1220,7 +1350,7 @@ class UpdateAgent(Resource):
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
- if not can_access(conn, "source", sid, user):
+ if not _ref_attachable(conn, "source", sid, owner_id, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
@@ -1228,28 +1358,24 @@ class UpdateAgent(Resource):
if (
new_prompt_id
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
- and not can_access(conn, "prompt", new_prompt_id, user)
+ and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}), 403
)
- # A team editor must not attach tools they can't access onto a
- # shared agent: at run time the agent-key path resolves+decrypts
- # tools as the OWNER, so an unchecked tool here would let an
- # editor invoke arbitrary owner credentials. Owners are
- # unrestricted (they own their tools). Default/builtin synthetic
- # tool ids belong to no one and are always allowed.
- if is_team_editor and "tools" in update_fields:
- from docsgpt.agents.default_tools import is_synthesized_tool_id
-
+ # Tools run with the OWNER's credentials (the agent-key path
+ # resolves and decrypts them as the owner), so a newly attached
+ # tool must be the owner's or reach the caller with
+ # ``use_in_own``. Tools already on the agent stay. Builtin
+ # synthetic ids belong to no one and are always allowed.
+ if "tools" in update_fields:
existing_tools = {
str(t) for t in (existing_agent.get("tools") or [])
}
for tid in update_fields["tools"] or []:
- tid_s = str(tid)
- if tid_s in existing_tools or is_synthesized_tool_id(tid_s):
+ if str(tid) in existing_tools:
continue
- if not can_access(conn, "tool", tid_s, user):
+ if not _tool_attachable(conn, tid, owner_id, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
@@ -1257,19 +1383,13 @@ class UpdateAgent(Resource):
403,
)
- # Per-agent quota lives on the row and is pooled across all
- # members; only the owner may resize that shared pool, so a
- # team editor's quota changes are dropped.
- if is_team_editor:
- for _q in (
- "token_limit", "request_limit",
- "limited_token_mode", "limited_request_mode",
- # Guardrails are the owner's policy for their agent.
- # An editor who could clear them would silently strip
- # protection from everyone else using it.
- "config",
- ):
- update_fields.pop(_q, None)
+ # Guardrails and the pooled quota are policy: an unchanged
+ # value re-sent by a full-form save is fine, a change needs
+ # ``edit_policy``.
+ if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields):
+ return _denied(
+ AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
+ )
# Apply update. Owner writes use the dual-key guard; team-editor
# writes go by-id (already authorized) with an optimistic-lock
@@ -1328,7 +1448,9 @@ class UpdateAgent(Resource):
"id": pg_agent_id,
"message": "Agent updated successfully",
}
- if newly_generated_key:
+ # A freshly minted key is an access detail: returned only to a caller
+ # who may manage it (the key is still stored either way).
+ if newly_generated_key and ra.can("manage_access_details"):
response_data["key"] = (
newly_generated_key
if may_see_agent_keys(request)
@@ -1358,10 +1480,13 @@ class RegenerateAgentKey(Resource):
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
- # Owner-only: rotating a credential is destructive to live
- # integrations, so this is intentionally stricter than
- # update_agent (which also allows team editors).
- existing_agent = agents_repo.get_any(agent_id, user)
+ # Rotating the key is an access detail: the owner, or an editor
+ # while ``editors_can_manage_access_details`` is on.
+ try:
+ ra = require(conn, "agent", agent_id, user, "manage_access_details")
+ except AccessDenied as denied:
+ return _denied(denied)
+ existing_agent = agents_repo.get_by_id(ra.resource_id)
if not existing_agent:
return make_response(
jsonify(
@@ -1389,7 +1514,7 @@ class RegenerateAgentKey(Resource):
)
new_key = str(uuid.uuid4())
- updated = agents_repo.update(pg_agent_id, user, {"key": new_key})
+ updated = agents_repo.update(pg_agent_id, ra.owner_id, {"key": new_key})
if not updated:
return make_response(
jsonify(
@@ -1460,7 +1585,12 @@ class DeleteAgent(Resource):
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
- agent = agents_repo.get_any(agent_id, user)
+ try:
+ ra = require(conn, "agent", agent_id, user, "delete")
+ except AccessDenied as denied:
+ return _denied(denied)
+ owner_id = ra.owner_id
+ agent = agents_repo.get_by_id(ra.resource_id)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
@@ -1476,14 +1606,20 @@ class DeleteAgent(Resource):
# that user's graph.
if agent.get("agent_type") == "workflow" and workflow_id:
try:
- WorkflowsRepository(conn).delete(str(workflow_id), user)
+ WorkflowsRepository(conn).delete(str(workflow_id), owner_id)
except Exception as wf_err:
current_app.logger.warning(
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
)
- agents_repo.delete(pg_agent_id, user)
- # Strip pinned/shared entries for this agent from the owner's prefs.
- UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
+ # Team grants go with the row (AFTER DELETE trigger, 0021);
+ # the switches table has no FK, so drop it explicitly.
+ agents_repo.delete(pg_agent_id, owner_id)
+ delete_settings(conn, "agent", pg_agent_id)
+ # Strip pinned/shared entries for this agent from the owner's
+ # (and the deleting editor's) prefs.
+ users_repo = UsersRepository(conn)
+ for uid in {owner_id, user}:
+ users_repo.remove_agent_from_all(uid, pg_agent_id)
record_event(
conn,
"agent.deleted",
@@ -1579,10 +1715,19 @@ class PinnedAgents(Resource):
for agent in pinned_agents
if _user_may_pin(conn, agent, user_id, shared_with_me)
]
+ # A pin reached only through a share link (or a template)
+ # has no grant: chat + pin, nothing more.
+ access_by_id = {}
+ for agent in pinned_agents:
+ ra = resolve(conn, "agent", str(agent["id"]), user_id)
+ access_by_id[str(agent["id"])] = (
+ ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS)
+ )
list_pinned_agents = []
for agent in pinned_agents:
source_id = agent.get("source_id")
+ access = access_by_id[str(agent["id"])]
list_pinned_agents.append(
{
"id": str(agent["id"]),
@@ -1608,10 +1753,12 @@ class PinnedAgents(Resource):
"last_used_at": agent.get("last_used_at", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
- if agent.get("key") and agent.get("user_id") == user_id
+ if agent.get("key")
+ and "manage_access_details" in access["allowed_actions"]
else ""
),
"pinned": True,
+ **access,
}
)
except Exception as err:
diff --git a/docsgpt/api/user/agents/sharing.py b/docsgpt/api/user/agents/sharing.py
index f15a827d..508ab639 100644
--- a/docsgpt/api/user/agents/sharing.py
+++ b/docsgpt/api/user/agents/sharing.py
@@ -10,6 +10,7 @@ from sqlalchemy import text as _sql_text
from docsgpt.api import api
from docsgpt.core.settings import settings
from docsgpt.api.user.base import resolve_tool_details
+from docsgpt.api.user.resource_access import AccessDenied, require, resolve
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
@@ -21,12 +22,38 @@ agents_sharing_ns = Namespace(
)
+# A caller who reached an agent only through its public link may chat with it
+# and pin it; a team grant, when there is one, gives more.
+LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
+
+
+def _link_access(conn, agent_id: str, user_id) -> dict:
+ """The ``access`` payload for an agent the caller reached by share link.
+
+ Args:
+ conn: Open database connection.
+ agent_id: The agent's id.
+ user_id: The caller, or None when anonymous.
+
+ Returns:
+ The caller's own access (owner or a team grant) when they have one,
+ else viewer with ``pin`` and ``use``.
+ """
+ if user_id:
+ ra = resolve(conn, "agent", agent_id, user_id)
+ if ra is not None:
+ return ra.payload()
+ return dict(LINK_SHARED_ACCESS)
+
+
def _serialize_agent_basic(agent: dict) -> dict:
- """Shape a PG agent row into the API response dict."""
+ """Shape a PG agent row into the API response dict.
+
+ The owner's user id is deliberately not included: a share link is public.
+ """
source_id = agent.get("source_id")
return {
"id": str(agent["id"]),
- "user": agent.get("user_id", ""),
"name": agent.get("name", ""),
"image": (
generate_image_url(
@@ -91,8 +118,8 @@ class SharedAgent(Resource):
enriched_tools.append(detail.get("name", ""))
data["tools"] = enriched_tools
decoded_token = getattr(request, "decoded_token", None)
- if decoded_token:
- user_id = decoded_token.get("sub")
+ user_id = decoded_token.get("sub") if decoded_token else None
+ if user_id:
owner_id = shared_agent.get("user_id")
if user_id != owner_id:
@@ -100,6 +127,8 @@ class SharedAgent(Resource):
users_repo = UsersRepository(conn)
users_repo.upsert(user_id)
users_repo.add_shared(user_id, agent_id)
+ with db_readonly() as conn:
+ data.update(_link_access(conn, agent_id, user_id))
return make_response(jsonify(data), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving shared agent: {err}")
@@ -152,6 +181,10 @@ class SharedAgents(Resource):
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
+ access_by_id = {
+ str(agent["id"]): _link_access(conn, str(agent["id"]), user_id)
+ for agent in shared_agents
+ }
list_shared_agents = []
for agent in shared_agents:
@@ -185,6 +218,7 @@ class SharedAgents(Resource):
"shared": bool(agent.get("shared", False)),
"shared_token": agent.get("shared_token", "") or "",
"shared_metadata": agent.get("shared_metadata", {}) or {},
+ **access_by_id[agent_id_str],
}
)
@@ -244,7 +278,15 @@ class ShareAgent(Resource):
try:
with db_session() as conn:
repo = AgentsRepository(conn)
- agent = repo.get_any(agent_id, user)
+ # The public link is an access detail; it is written as the owner.
+ try:
+ ra = require(conn, "agent", agent_id, user, "manage_access_details")
+ except AccessDenied as denied:
+ return make_response(
+ jsonify({"success": False, "message": denied.message}), denied.status
+ )
+ owner_id = ra.owner_id
+ agent = repo.get_by_id(ra.resource_id)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
@@ -258,7 +300,7 @@ class ShareAgent(Resource):
}
shared_token = secrets.token_urlsafe(32)
repo.update(
- str(agent["id"]), user,
+ str(agent["id"]), owner_id,
{
"shared": True,
"shared_token": shared_token,
@@ -267,7 +309,7 @@ class ShareAgent(Resource):
)
else:
repo.update(
- str(agent["id"]), user,
+ str(agent["id"]), owner_id,
{
"shared": False,
"shared_token": None,
diff --git a/docsgpt/api/user/agents/webhooks.py b/docsgpt/api/user/agents/webhooks.py
index 7c334a63..56244317 100644
--- a/docsgpt/api/user/agents/webhooks.py
+++ b/docsgpt/api/user/agents/webhooks.py
@@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text
from docsgpt.api import api
from docsgpt.api.user.base import require_agent
+from docsgpt.api.user.resource_access import AccessDenied, require
from docsgpt.api.user.tasks import process_agent_webhook
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
@@ -71,7 +72,14 @@ class AgentWebhook(Resource):
)
try:
with db_readonly() as conn:
- agent = AgentsRepository(conn).get_any(agent_id, user)
+ # The webhook URL is an access detail; it is minted as the owner.
+ try:
+ ra = require(conn, "agent", agent_id, user, "manage_access_details")
+ except AccessDenied as denied:
+ return make_response(
+ jsonify({"success": False, "message": denied.message}), denied.status
+ )
+ agent = AgentsRepository(conn).get_by_id(ra.resource_id)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
@@ -81,7 +89,7 @@ class AgentWebhook(Resource):
webhook_token = secrets.token_urlsafe(32)
with db_session() as conn:
AgentsRepository(conn).update(
- str(agent["id"]), user,
+ str(agent["id"]), ra.owner_id,
{"incoming_webhook_token": webhook_token},
)
base_url = settings.API_URL.rstrip("/")
diff --git a/docsgpt/api/user/analytics/routes.py b/docsgpt/api/user/analytics/routes.py
index 3642ed3a..14270670 100644
--- a/docsgpt/api/user/analytics/routes.py
+++ b/docsgpt/api/user/analytics/routes.py
@@ -8,6 +8,7 @@ from flask_restx import fields, Namespace, Resource
from sqlalchemy import Connection, text as _sql_text
from docsgpt.api import api
+from docsgpt.api.user.resource_access import AccessDenied, resolve
from docsgpt.api.user.base import (
generate_date_range,
generate_hourly_range,
@@ -74,27 +75,38 @@ def _intervals_for_filter(filter_option, start_date, end_date):
def _resolve_agent(conn, api_key_id, user_id):
- """Owner-scoped agent lookup for analytics filters.
+ """Access-checked agent lookup for analytics filters.
Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when
- the id doesn't resolve to one of the caller's agents — callers must
+ the id doesn't resolve to an agent the caller can see — callers must
short-circuit with an empty result, not fall back to sentinel filter
- values. ``api_key`` is ``None`` (never ``""``) for key-less agents:
- draft agents store ``key = ''``, and an ``''`` filter would match the
- ``''`` that writers like ``stack_logs`` stamp on every key-less
- request — leaking rows across users. NULL matches nothing. Accepts
- UUID or legacy Mongo ObjectId ids.
+ values. A visible agent needs ``view_logs`` (owner and editors; viewers
+ when the owner turns on ``viewers_can_see_logs``), and the caller then
+ sees exactly the owner's view of it. ``api_key`` is ``None`` (never
+ ``""``) for key-less agents: draft agents store ``key = ''``, and an
+ ``''`` filter would match the ``''`` that writers like ``stack_logs``
+ stamp on every key-less request — leaking rows across users. NULL
+ matches nothing. Accepts UUID or legacy Mongo ObjectId ids.
+
+ Raises:
+ AccessDenied: 403 when the agent is visible but ``view_logs`` isn't allowed.
"""
- agent = (
- AgentsRepository(conn).get_any(api_key_id, user_id)
- if api_key_id
- else None
- )
+ ra = resolve(conn, "agent", api_key_id, user_id) if api_key_id else None
+ if ra is None:
+ return None, None, None
+ if not ra.can("view_logs"):
+ raise AccessDenied(403, "Your access to this agent doesn't include its logs")
+ agent = AgentsRepository(conn).get_by_id(ra.resource_id)
api_key = (agent or {}).get("key") or None
agent_pg_id = str(agent["id"]) if agent else None
return agent, api_key, agent_pg_id
+def _denied(err: AccessDenied):
+ """The JSON error response for an :class:`AccessDenied`."""
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
+
+
def _trace_branch(name: str, sources_sql: str, scope: str) -> dict:
"""A ``get_user_logs`` branch listing stored traces of the given sources."""
return {
@@ -238,6 +250,8 @@ class GetTraces(Resource):
traces = RequestTracesRepository(conn).list_by_ref(
field, value, user_id=user, agent_id=agent_pg_id
)
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(f"Error getting traces: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -342,6 +356,8 @@ class GetMessageAnalytics(Resource):
daily_messages = {interval: 0 for interval in intervals}
for row in rows:
daily_messages[row._mapping["bucket"]] = int(row._mapping["count"])
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting message analytics: {err}", exc_info=True
@@ -466,6 +482,8 @@ class GetTokenAnalytics(Resource):
if key not in series:
series[key] = {interval: 0 for interval in intervals}
series[key][bucket] = series[key].get(bucket, 0) + total
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting token analytics: {err}", exc_info=True
@@ -592,6 +610,8 @@ class GetFeedbackAnalytics(Resource):
"positive": int(row._mapping["positive"] or 0),
"negative": int(row._mapping["negative"] or 0),
}
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting feedback analytics: {err}", exc_info=True
@@ -710,6 +730,8 @@ class GetToolAnalytics(Resource):
}
for row in rows
]
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting tool analytics: {err}", exc_info=True
@@ -825,6 +847,8 @@ class GetScheduleAnalytics(Resource):
"failed": int(row._mapping["failed"] or 0),
"skipped": int(row._mapping["skipped"] or 0),
}
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting schedule analytics: {err}", exc_info=True
@@ -927,7 +951,8 @@ class GetUserLogs(Resource):
200,
)
params: dict = {
- "user_id": user,
+ # Agent-scoped logs are the owner's view of the agent.
+ "user_id": agent["user_id"] if agent else user,
"limit": page_size + 1,
"offset": (page - 1) * page_size,
}
@@ -1313,6 +1338,8 @@ class GetUserLogs(Resource):
"Could not attach trace summaries to the logs page",
exc_info=True,
)
+ except AccessDenied as denied:
+ return _denied(denied)
except Exception as err:
current_app.logger.error(
f"Error getting user logs: {err}", exc_info=True
diff --git a/docsgpt/api/user/prompts/routes.py b/docsgpt/api/user/prompts/routes.py
index af5b9867..88428d8f 100644
--- a/docsgpt/api/user/prompts/routes.py
+++ b/docsgpt/api/user/prompts/routes.py
@@ -6,7 +6,14 @@ from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
from docsgpt.api.pat.rules import filter_listing
-from docsgpt.api.user.team_sharing import team_access_for, visible_with_access
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ delete_settings,
+ payload_for,
+ require,
+ settings_many,
+)
+from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.prompts.composer import compose_preset, is_composed_preset
from docsgpt.storage.db.session import db_readonly, db_session
@@ -17,6 +24,16 @@ prompts_ns = Namespace(
)
+def _denied(err: AccessDenied):
+ """JSON response for an :class:`AccessDenied` (403 or 404)."""
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
+
+
+def _iso(value):
+ """ISO-8601 string for a timestamp (``expected_updated_at`` round-trips it)."""
+ return value.isoformat() if hasattr(value, "isoformat") else value
+
+
@prompts_ns.route("/create_prompt")
class CreatePrompt(Resource):
create_prompt_model = api.model(
@@ -67,26 +84,35 @@ class GetPrompts(Resource):
team_shared = visible_with_access(conn, user, "prompt")
shared_ids = [pid for pid in team_shared if pid not in owned_ids]
shared_prompts = repo.list_by_ids(shared_ids)
+ switches = settings_many(
+ conn, "prompt", [*owned_ids, *(str(p["id"]) for p in shared_prompts)]
+ )
list_prompts = [
{"id": "default", "name": "default", "type": "public"},
{"id": "creative", "name": "creative", "type": "public"},
{"id": "strict", "name": "strict", "type": "public"},
]
for prompt in prompts:
+ pid = str(prompt["id"])
list_prompts.append(
{
- "id": str(prompt["id"]),
+ "id": pid,
"name": prompt["name"],
"type": "private",
+ "updated_at": _iso(prompt.get("updated_at")),
+ **payload_for("prompt", "owner", switches.get(pid)),
}
)
for prompt in shared_prompts:
+ pid = str(prompt["id"])
list_prompts.append(
{
- "id": str(prompt["id"]),
+ "id": pid,
"name": prompt["name"],
"type": "team",
- "team_access": team_shared.get(str(prompt["id"])),
+ "team_access": team_shared.get(pid),
+ "updated_at": _iso(prompt.get("updated_at")),
+ **payload_for("prompt", team_shared.get(pid), switches.get(pid)),
}
)
except Exception as err:
@@ -115,19 +141,28 @@ class GetSinglePrompt(Resource):
jsonify({"content": compose_preset(prompt_id)}), 200
)
with db_readonly() as conn:
- repo = PromptsRepository(conn)
- prompt = repo.get_any(prompt_id, user)
- if not prompt and team_access_for(conn, user, "prompt", prompt_id):
- # Team fallback: ownerless fetch only after a grant check.
- prompt = repo.get_for_rendering(prompt_id)
+ ra = require(conn, "prompt", prompt_id, user, "use")
+ prompt = PromptsRepository(conn).get_any(ra.resource_id, ra.owner_id)
if not prompt:
return make_response(
jsonify({"success": False, "message": "Prompt not found"}), 404
)
+ except AccessDenied as err:
+ return _denied(err)
except Exception as err:
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
- return make_response(jsonify({"content": prompt["content"]}), 200)
+ return make_response(
+ jsonify(
+ {
+ "content": prompt["content"],
+ "name": prompt.get("name"),
+ "updated_at": _iso(prompt.get("updated_at")),
+ **ra.payload(),
+ }
+ ),
+ 200,
+ )
@prompts_ns.route("/delete_prompt")
@@ -151,14 +186,12 @@ class DeletePrompt(Resource):
return missing_fields
try:
with db_session() as conn:
- repo = PromptsRepository(conn)
- prompt = repo.get_any(data["id"], user)
- if not prompt:
- return make_response(
- jsonify({"success": False, "message": "Prompt not found"}),
- 404,
- )
- repo.delete(str(prompt["id"]), user)
+ ra = require(conn, "prompt", data["id"], user, "delete")
+ # Grants go with the row (delete trigger); switches have no FK.
+ PromptsRepository(conn).delete(ra.resource_id, ra.owner_id)
+ delete_settings(conn, "prompt", ra.resource_id)
+ except AccessDenied as err:
+ return _denied(err)
except Exception as err:
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -192,43 +225,26 @@ class UpdatePrompt(Resource):
return missing_fields
try:
with db_session() as conn:
- repo = PromptsRepository(conn)
- prompt = repo.get_any(data["id"], user)
- if prompt:
- repo.update(str(prompt["id"]), user, data["name"], data["content"])
- else:
- # Team editor write path (viewer is read-only).
- access = team_access_for(conn, user, "prompt", data["id"])
- if access == "editor":
- result = repo.update_by_id(
- data["id"],
- data["name"],
- data["content"],
- expected_updated_at=data.get("expected_updated_at"),
- )
- if result is None:
- return make_response(
- jsonify(
- {
- "success": False,
- "message": "Prompt was modified by someone else",
- "code": "stale_write",
- }
- ),
- 409,
- )
- elif access == "viewer":
- return make_response(
- jsonify(
- {"success": False, "message": "Read-only: editor access required"}
- ),
- 403,
- )
- else:
- return make_response(
- jsonify({"success": False, "message": "Prompt not found"}),
- 404,
- )
+ ra = require(conn, "prompt", data["id"], user, "edit")
+ result = PromptsRepository(conn).update_by_id(
+ ra.resource_id,
+ data["name"],
+ data["content"],
+ expected_updated_at=data.get("expected_updated_at"),
+ )
+ if result is None:
+ return make_response(
+ jsonify(
+ {
+ "success": False,
+ "message": "Prompt was modified by someone else",
+ "code": "stale_write",
+ }
+ ),
+ 409,
+ )
+ except AccessDenied as err:
+ return _denied(err)
except Exception as err:
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py
new file mode 100644
index 00000000..574e9487
--- /dev/null
+++ b/docsgpt/api/user/resource_access.py
@@ -0,0 +1,334 @@
+"""The one access check for team-shared resources: roles, actions and switches.
+
+Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an
+owner and may be shared to teams as ``viewer`` or ``editor``. What each role
+may do is a fixed table of *actions* per resource type (``ACTIONS``). The
+owner can adjust a few of those rows on one resource with *switches*
+(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves
+one action between two roles; it never touches owner-only actions such as
+``manage_settings``.
+
+Routes ask one question, ``require(conn, type, id, user, action)``, and get
+back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or
+an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the
+role may not do this). List and get responses embed ``ResourceAccess.payload()``
+(``access`` + ``allowed_actions``) so the frontend never re-derives the rules.
+
+Access is resolved live on every call (grants JOIN ``team_members``), so a
+revoked grant or membership denies on the next request.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from typing import Iterable, Optional
+
+from sqlalchemy import Connection, text
+
+from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.sources import SourcesRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+
+RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
+
+# Weakest role that may perform each action by default. ``owner`` rows are
+# owner-only unless a switch below moves them.
+ACTIONS: dict[str, dict[str, str]] = {
+ "agent": {
+ "use": "viewer", # chat with it
+ "pin": "viewer",
+ "view": "editor", # open the edit page and read its full config
+ "edit": "editor",
+ "publish": "editor",
+ "edit_policy": "editor", # guardrails and quotas
+ "view_logs": "editor",
+ "manage_schedules": "editor",
+ "export": "editor",
+ "manage_access_details": "editor", # API key, webhook, public link
+ "move_folder": "owner",
+ "share": "owner",
+ "delete": "owner",
+ "manage_settings": "owner",
+ },
+ "source": {
+ "use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents
+ "view_config": "editor",
+ "edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert
+ "reconnect": "owner", # change the connector account
+ "share": "owner",
+ "delete": "owner",
+ "manage_settings": "owner",
+ },
+ "tool": {
+ "use": "viewer", # see it and run it inside the owner's shared agents
+ "use_in_own": "viewer", # add it to my own agents and chats
+ "edit": "editor", # name, action descriptions, parameters, approval
+ "edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
+ "share": "owner",
+ "delete": "owner",
+ "manage_settings": "owner",
+ },
+ "prompt": {
+ "use": "viewer", # read it and use it in my own agents
+ "duplicate": "editor",
+ "edit": "editor",
+ "share": "owner",
+ "delete": "owner",
+ "manage_settings": "owner",
+ },
+}
+
+
+@dataclass(frozen=True)
+class Switch:
+ """One owner switch: moves ``action`` to ``role_on`` or ``role_off``."""
+
+ key: str
+ default: bool
+ action: str
+ role_on: str
+ role_off: str
+
+
+# Order is the order the share dialog lists them in.
+SWITCHES: dict[str, tuple[Switch, ...]] = {
+ "agent": (
+ Switch("editors_can_share", False, "share", "editor", "owner"),
+ Switch("editors_can_delete", False, "delete", "editor", "owner"),
+ Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"),
+ Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"),
+ ),
+ "source": (
+ Switch("editors_can_share", False, "share", "editor", "owner"),
+ Switch("editors_can_delete", False, "delete", "editor", "owner"),
+ Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"),
+ ),
+ "tool": (
+ Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"),
+ Switch("editors_can_share", False, "share", "editor", "owner"),
+ Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"),
+ ),
+ "prompt": (
+ Switch("editors_can_share", False, "share", "editor", "owner"),
+ Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"),
+ ),
+}
+
+_RANK = {"viewer": 1, "editor": 2, "owner": 3}
+
+_REPO_FOR_TYPE = {
+ "agent": AgentsRepository,
+ "source": SourcesRepository,
+ "prompt": PromptsRepository,
+ "tool": UserToolsRepository,
+}
+
+
+class AccessDenied(Exception):
+ """Raised by :func:`require`; ``status`` is 404 (not visible) or 403."""
+
+ def __init__(self, status: int, message: str) -> None:
+ super().__init__(message)
+ self.status = status
+ self.message = message
+
+
+def default_settings(resource_type: str) -> dict[str, bool]:
+ """Every switch of ``resource_type`` at its default."""
+ return {s.key: s.default for s in SWITCHES.get(resource_type, ())}
+
+
+def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]:
+ merged = default_settings(resource_type)
+ for key, value in (stored or {}).items():
+ if key in merged and isinstance(value, bool):
+ merged[key] = value
+ return merged
+
+
+def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]:
+ """``action -> weakest role`` for one resource, switches applied."""
+ table = dict(ACTIONS[resource_type])
+ merged = _merge(resource_type, settings)
+ for switch in SWITCHES.get(resource_type, ()):
+ table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off
+ return table
+
+
+def allowed_actions(
+ resource_type: str, access: Optional[str], settings: Optional[dict]
+) -> set[str]:
+ """The actions ``access`` may perform on a resource with these switches."""
+ if access not in _RANK or resource_type not in ACTIONS:
+ return set()
+ rank = _RANK[access]
+ return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]}
+
+
+def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
+ """The switches as ``[{key, value, default}]`` in display order."""
+ merged = _merge(resource_type, settings)
+ return [
+ {"key": s.key, "value": merged[s.key], "default": s.default}
+ for s in SWITCHES.get(resource_type, ())
+ ]
+
+
+def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
+ """The resource's switches, defaults filled in."""
+ return settings_many(conn, resource_type, [resource_id])[resource_id]
+
+
+def settings_many(
+ conn: Connection, resource_type: str, resource_ids: Iterable[str]
+) -> dict[str, dict[str, bool]]:
+ """``resource_id -> switches`` for many resources in one query."""
+ ids = [str(r) for r in resource_ids]
+ out = {rid: default_settings(resource_type) for rid in ids}
+ uuids = [rid for rid in ids if looks_like_uuid(rid)]
+ if not uuids:
+ return out
+ rows = conn.execute(
+ text(
+ """
+ SELECT resource_id, settings FROM resource_share_settings
+ WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[]))
+ """
+ ),
+ {"t": resource_type, "ids": uuids},
+ ).fetchall()
+ for rid, stored in rows:
+ out[str(rid)] = _merge(resource_type, stored)
+ return out
+
+
+def set_settings(
+ conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str
+) -> dict[str, bool]:
+ """Merge ``changes`` into the resource's switches and return the result.
+
+ Raises:
+ ValueError: an unknown key or a non-boolean value.
+ """
+ known = default_settings(resource_type)
+ for key, value in changes.items():
+ if key not in known:
+ raise ValueError(f"Unknown setting: {key}")
+ if not isinstance(value, bool):
+ raise ValueError(f"Setting {key} must be true or false")
+ merged = {**settings_for(conn, resource_type, resource_id), **changes}
+ conn.execute(
+ text(
+ """
+ INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by)
+ VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by)
+ ON CONFLICT (resource_type, resource_id)
+ DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by,
+ updated_at = now()
+ """
+ ),
+ {"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by},
+ )
+ return merged
+
+
+def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None:
+ """Drop a deleted resource's switches (the table has no FK to cascade)."""
+ if looks_like_uuid(resource_id):
+ conn.execute(
+ text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"),
+ {"t": resource_type, "id": resource_id},
+ )
+
+
+@dataclass(frozen=True)
+class ResourceAccess:
+ """What one user may do on one resource."""
+
+ resource_type: str
+ resource_id: str
+ access: str # owner | editor | viewer
+ owner_id: str # the id to read and write the resource as
+ settings: dict = field(default_factory=dict)
+ actions: frozenset = frozenset()
+
+ def can(self, action: str) -> bool:
+ return action in self.actions
+
+ def payload(self) -> dict:
+ """The fields every API response embeds for this resource."""
+ return {"access": self.access, "allowed_actions": sorted(self.actions)}
+
+
+def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess:
+ """A :class:`ResourceAccess` from already-known parts (list endpoints)."""
+ merged = _merge(resource_type, settings)
+ return ResourceAccess(
+ resource_type=resource_type,
+ resource_id=str(resource_id),
+ access=access,
+ owner_id=owner_id,
+ settings=merged,
+ actions=frozenset(allowed_actions(resource_type, access, merged)),
+ )
+
+
+def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict:
+ """``access`` + ``allowed_actions`` without an owner lookup (list endpoints)."""
+ return {
+ "access": access,
+ "allowed_actions": sorted(allowed_actions(resource_type, access, settings)),
+ }
+
+
+def resolve(
+ conn: Connection, resource_type: str, resource_id: str, user_id: str
+) -> Optional[ResourceAccess]:
+ """The caller's access to a resource, or None when they can't see it."""
+ repo_cls = _REPO_FOR_TYPE.get(resource_type)
+ if repo_cls is None or not resource_id or not user_id:
+ return None
+ owned = repo_cls(conn).get_any(str(resource_id), user_id)
+ if owned is not None:
+ rid = str(owned.get("id") or resource_id)
+ return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
+ # Only canonical UUIDs can carry a grant; casting anything else would
+ # poison the transaction.
+ if not looks_like_uuid(str(resource_id)):
+ return None
+ level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id))
+ if level is None:
+ return None
+ grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id))
+ if not grants:
+ return None
+ # Every grant row carries the same denormalised owner id.
+ owner_id = grants[0].get("owner_id")
+ return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id)))
+
+
+def require(
+ conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str
+) -> ResourceAccess:
+ """Resolve and check one action.
+
+ Raises:
+ KeyError: ``action`` is not an action of ``resource_type`` (a bug).
+ AccessDenied: 404 when the resource isn't visible, 403 when the
+ caller's role may not perform ``action``.
+ """
+ if action not in ACTIONS.get(resource_type, {}):
+ raise KeyError(f"{resource_type} has no action {action!r}")
+ ra = resolve(conn, resource_type, resource_id, user_id)
+ if ra is None:
+ raise AccessDenied(404, f"{resource_type.capitalize()} not found")
+ if not ra.can(action):
+ raise AccessDenied(403, "Your access to this item doesn't allow that")
+ return ra
diff --git a/docsgpt/api/user/scheduler_worker.py b/docsgpt/api/user/scheduler_worker.py
index 3b2d8e62..fe581759 100644
--- a/docsgpt/api/user/scheduler_worker.py
+++ b/docsgpt/api/user/scheduler_worker.py
@@ -206,6 +206,33 @@ def _append_one_time_turn(
return message
+def _scheduler_still_allowed(schedule: Dict[str, Any], agent_config: Dict[str, Any]) -> bool:
+ """Whether the schedule's user may still run this agent.
+
+ The run always executes as the agent's owner. A schedule stored under
+ someone else (set from chat on a shared agent) needs that user to still
+ see the agent — a live team grant, or a public link that is still on —
+ so revoking a grant stops their schedules on the next tick.
+
+ Args:
+ schedule: The schedule row.
+ agent_config: The agent row (or the agentless ephemeral config).
+
+ Returns:
+ True when the run may proceed.
+ """
+ user_id = schedule.get("user_id")
+ agent_id = agent_config.get("id")
+ if not agent_id or not user_id or agent_config.get("user_id") == user_id:
+ return True
+ if agent_config.get("shared"):
+ return True
+ from docsgpt.api.user.resource_access import resolve
+
+ with get_engine().connect() as conn:
+ return resolve(conn, "agent", str(agent_id), user_id) is not None
+
+
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
"""Execute one scheduled run by id; returns a result dict for tracing."""
if not settings.POSTGRES_URI:
@@ -256,6 +283,22 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di
error="agent missing")
return {"status": "failed", "reason": "agent missing"}
+ if not _scheduler_still_allowed(schedule, agent_config):
+ with engine.begin() as conn:
+ updated = ScheduleRunsRepository(conn).update(
+ run_id,
+ {
+ "status": "failed",
+ "finished_at": datetime.now(timezone.utc),
+ "error_type": "internal",
+ "error": "agent access revoked",
+ },
+ )
+ SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
+ _publish_run_event("schedule.run.failed", updated or run, schedule,
+ error="agent access revoked")
+ return {"status": "failed", "reason": "agent access revoked"}
+
with engine.begin() as conn:
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
return {"status": "skipped", "reason": "lost race to mark_running"}
diff --git a/docsgpt/api/user/schedules/routes.py b/docsgpt/api/user/schedules/routes.py
index b63e39a3..6b15db57 100644
--- a/docsgpt/api/user/schedules/routes.py
+++ b/docsgpt/api/user/schedules/routes.py
@@ -1,4 +1,11 @@
-"""Schedules REST API (owner-scoped via request.decoded_token)."""
+"""Schedules REST API.
+
+A schedule on an agent belongs to the agent's owner: it is stored (and runs)
+under the owner's ``user_id`` whoever creates it, and managing it needs
+``manage_schedules`` on the agent (owner and team editors). A schedule the
+caller made themselves (e.g. via the chat scheduler tool on a shared agent)
+stays theirs to manage.
+"""
from __future__ import annotations
@@ -20,6 +27,7 @@ from docsgpt.agents.scheduler_utils import (
resolve_timezone,
)
from docsgpt.api import api
+from docsgpt.api.user.resource_access import AccessDenied, require
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -103,11 +111,70 @@ def _format_run(row: Dict[str, Any]) -> Dict[str, Any]:
return out
-def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]:
+def _agent_for_schedules(agent_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
+ """The agent row and the id its schedules live under.
+
+ Args:
+ agent_id: Agent id from the URL.
+ user_id: The caller.
+
+ Returns:
+ ``(agent, owner_id)``.
+
+ Raises:
+ AccessDenied: 404 when the agent isn't visible, 403 without
+ ``manage_schedules``.
+ """
if not looks_like_uuid(str(agent_id)):
- return None
+ raise AccessDenied(404, "agent not found")
with db_readonly() as conn:
- return AgentsRepository(conn).get_any(agent_id, user_id)
+ try:
+ ra = require(conn, "agent", agent_id, user_id, "manage_schedules")
+ except AccessDenied as denied:
+ if denied.status == 404:
+ raise AccessDenied(404, "agent not found")
+ raise
+ agent = AgentsRepository(conn).get_by_id(ra.resource_id)
+ if agent is None:
+ raise AccessDenied(404, "agent not found")
+ return agent, ra.owner_id
+
+
+def _schedule_for(conn, schedule_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
+ """Fetch a schedule the caller may manage, and the id to act as.
+
+ The caller's own schedule is always theirs. Otherwise it must be a
+ schedule of an agent they hold ``manage_schedules`` on, stored under
+ that agent's owner (another member's private schedule stays hidden).
+
+ Args:
+ conn: Open database connection.
+ schedule_id: Schedule UUID.
+ user_id: The caller.
+
+ Returns:
+ ``(schedule, acting_user_id)``.
+
+ Raises:
+ AccessDenied: 404 when not visible, 403 when the role can't manage it.
+ """
+ row = SchedulesRepository(conn).get_internal(schedule_id)
+ if row is None:
+ raise AccessDenied(404, "schedule not found")
+ if row.get("user_id") == user_id:
+ return row, user_id
+ agent_id = row.get("agent_id")
+ if not agent_id:
+ raise AccessDenied(404, "schedule not found")
+ try:
+ ra = require(conn, "agent", str(agent_id), user_id, "manage_schedules")
+ except AccessDenied as denied:
+ if denied.status == 404:
+ raise AccessDenied(404, "schedule not found")
+ raise
+ if row.get("user_id") != ra.owner_id:
+ raise AccessDenied(404, "schedule not found")
+ return row, ra.owner_id
def _user_id() -> Optional[str]:
@@ -150,13 +217,14 @@ class AgentSchedules(Resource):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
- agent = _agent_owned(agent_id, user_id)
- if agent is None:
- return _err("agent not found", 404)
+ try:
+ agent, owner_id = _agent_for_schedules(agent_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
try:
with db_readonly() as conn:
rows = SchedulesRepository(conn).list_for_agent(
- str(agent["id"]), user_id,
+ str(agent["id"]), owner_id,
)
except Exception as exc:
current_app.logger.error("list schedules failed: %s", exc, exc_info=True)
@@ -195,9 +263,10 @@ class AgentSchedules(Resource):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
- agent = _agent_owned(agent_id, user_id)
- if agent is None:
- return _err("agent not found", 404)
+ try:
+ agent, owner_id = _agent_for_schedules(agent_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
data = request.get_json(silent=True) or {}
instruction = (data.get("instruction") or "").strip()
tz_name = (data.get("timezone") or "UTC").strip() or "UTC"
@@ -219,7 +288,7 @@ class AgentSchedules(Resource):
except (TypeError, ValueError):
return _err("token_budget must be a non-negative integer")
with db_readonly() as conn:
- count = SchedulesRepository(conn).count_active_for_user(user_id)
+ count = SchedulesRepository(conn).count_active_for_user(owner_id)
if (
settings.SCHEDULE_MAX_PER_USER > 0
and count >= settings.SCHEDULE_MAX_PER_USER
@@ -240,7 +309,7 @@ class AgentSchedules(Resource):
try:
with db_session() as conn:
created = SchedulesRepository(conn).create(
- user_id=user_id,
+ user_id=owner_id,
agent_id=str(agent["id"]),
trigger_type="once",
instruction=instruction,
@@ -295,7 +364,7 @@ class AgentSchedules(Resource):
try:
with db_session() as conn:
created = SchedulesRepository(conn).create(
- user_id=user_id,
+ user_id=owner_id,
agent_id=str(agent["id"]),
trigger_type="recurring",
instruction=instruction,
@@ -337,9 +406,10 @@ class AgentScheduleStats(Resource):
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
- agent = _agent_owned(agent_id, user_id)
- if agent is None:
- return _err("agent not found", 404)
+ try:
+ agent, owner_id = _agent_for_schedules(agent_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
try:
days = max(1, min(int(request.args.get("days", 30)), 365))
except (TypeError, ValueError):
@@ -347,7 +417,7 @@ class AgentScheduleStats(Resource):
try:
with db_readonly() as conn:
stats = ScheduleRunsRepository(conn).stats_for_agent(
- str(agent["id"]), user_id, days=days,
+ str(agent["id"]), owner_id, days=days,
)
except Exception as exc:
current_app.logger.error(
@@ -377,9 +447,10 @@ class ScheduleResource(Resource):
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
with db_readonly() as conn:
- row = SchedulesRepository(conn).get(schedule_id, user_id)
- if row is None:
- return _err("schedule not found", 404)
+ try:
+ row, _acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
return _ok({"schedule": _format_schedule(row)})
@api.doc(description="Edit a schedule's editable fields.")
@@ -439,9 +510,10 @@ class ScheduleResource(Resource):
fields_in["end_at"] = None
# Recompute next_run_at when cron/tz changes.
with db_session() as conn:
- existing = SchedulesRepository(conn).get(schedule_id, user_id)
- if existing is None:
- return _err("schedule not found", 404)
+ try:
+ existing, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
if (
("cron" in fields_in or "timezone" in fields_in)
and existing.get("trigger_type") == "recurring"
@@ -467,7 +539,7 @@ class ScheduleResource(Resource):
except ScheduleValidationError as exc:
return _err(str(exc))
updated = SchedulesRepository(conn).update(
- schedule_id, user_id, fields_in,
+ schedule_id, acting, fields_in,
)
return _ok({"schedule": _format_schedule(updated or {})})
@@ -484,9 +556,10 @@ class ScheduleResource(Resource):
if action not in {"pause", "resume"}:
return _err("action must be 'pause' or 'resume'")
with db_session() as conn:
- existing = SchedulesRepository(conn).get(schedule_id, user_id)
- if existing is None:
- return _err("schedule not found", 404)
+ try:
+ existing, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
if existing.get("status") in ("cancelled", "completed"):
return _err("schedule is terminal", 409)
if action == "pause":
@@ -538,13 +611,13 @@ class ScheduleResource(Resource):
)
fields_in["next_run_at"] = run_at_dt
updated = SchedulesRepository(conn).update(
- schedule_id, user_id, fields_in,
+ schedule_id, acting, fields_in,
)
if action == "resume":
SchedulesRepository(conn).reset_failure_count(schedule_id)
if action == "resume" and updated:
_publish_schedule_event(
- user_id, "schedule.resumed", schedule_id, status="active",
+ acting, "schedule.resumed", schedule_id, status="active",
)
return _ok({"schedule": _format_schedule(updated or {})})
@@ -557,11 +630,15 @@ class ScheduleResource(Resource):
if not looks_like_uuid(schedule_id):
return _err("invalid schedule id", 400)
with db_session() as conn:
- ok = SchedulesRepository(conn).delete(schedule_id, user_id)
+ try:
+ _row, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
+ ok = SchedulesRepository(conn).delete(schedule_id, acting)
if not ok:
return _err("schedule not found", 404)
_publish_schedule_event(
- user_id, "schedule.cancelled", schedule_id, status="cancelled",
+ acting, "schedule.cancelled", schedule_id, status="cancelled",
)
return _ok({"success": True})
@@ -579,8 +656,12 @@ class ScheduleRunNow(Resource):
# FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique
# scheduled_for values would otherwise sneak past the unique index).
with db_session() as conn:
+ try:
+ _row, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
schedule = SchedulesRepository(conn).get_for_update(
- schedule_id, user_id,
+ schedule_id, acting,
)
if schedule is None:
return _err("schedule not found", 404)
@@ -590,9 +671,10 @@ class ScheduleRunNow(Resource):
return _err("a run is already in flight", 409)
scheduled_for = datetime.now(timezone.utc)
agent_id_raw = schedule.get("agent_id")
+ # The run belongs to (and executes as) the schedule's owner.
run = ScheduleRunsRepository(conn).record_pending(
schedule_id,
- user_id,
+ acting,
str(agent_id_raw) if agent_id_raw else None,
scheduled_for,
trigger_source="manual",
@@ -633,11 +715,12 @@ class ScheduleRunList(Resource):
except (TypeError, ValueError):
offset = 0
with db_readonly() as conn:
- schedule = SchedulesRepository(conn).get(schedule_id, user_id)
- if schedule is None:
- return _err("schedule not found", 404)
+ try:
+ _schedule, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
rows = ScheduleRunsRepository(conn).list_runs(
- schedule_id, user_id, limit=limit, offset=offset,
+ schedule_id, acting, limit=limit, offset=offset,
)
return _ok(
{
@@ -659,10 +742,11 @@ class ScheduleRunDetail(Resource):
if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id):
return _err("invalid id", 400)
with db_readonly() as conn:
- schedule = SchedulesRepository(conn).get(schedule_id, user_id)
- if schedule is None:
- return _err("schedule not found", 404)
- run = ScheduleRunsRepository(conn).get(run_id, user_id)
+ try:
+ schedule, acting = _schedule_for(conn, schedule_id, user_id)
+ except AccessDenied as denied:
+ return _err(denied.message, denied.status)
+ run = ScheduleRunsRepository(conn).get(run_id, acting)
if run is None or str(run.get("schedule_id")) != str(
schedule["id"]
):
diff --git a/docsgpt/api/user/sources/access.py b/docsgpt/api/user/sources/access.py
new file mode 100644
index 00000000..180fd951
--- /dev/null
+++ b/docsgpt/api/user/sources/access.py
@@ -0,0 +1,53 @@
+"""Role checks shared by the source routes (sources, chunks, upload, search).
+
+Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source
+endpoint resolves the row the same way and answers denials with the same
+JSON shape: 404 when the caller can't see the source, 403 when they can but
+their role may not perform the action.
+"""
+
+from __future__ import annotations
+
+from typing import Optional
+
+from flask import jsonify, make_response
+from sqlalchemy import Connection
+
+from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
+from docsgpt.storage.db.repositories.sources import SourcesRepository
+
+
+def load_source(
+ conn: Connection, source_id: Optional[str], user: str, action: str
+) -> tuple[dict, ResourceAccess]:
+ """Check ``action`` on a source and return its row with the caller's access.
+
+ The row is fetched by the canonical id only after the check passes, so a
+ team member gets the owner's row without ownership scoping.
+
+ Args:
+ conn: Open database connection.
+ source_id: Source id from the request (UUID or legacy id).
+ user: The caller's ``sub``.
+ action: A ``source`` action from ``resource_access.ACTIONS``.
+
+ Returns:
+ tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``.
+
+ Raises:
+ AccessDenied: 404 when not visible, 403 when the role can't do it.
+ """
+ if not source_id:
+ raise AccessDenied(404, "Source not found")
+ ra = require(conn, "source", str(source_id), user, action)
+ doc = SourcesRepository(conn).get_by_id(ra.resource_id)
+ if doc is None:
+ raise AccessDenied(404, "Source not found")
+ return doc, ra
+
+
+def denied_response(err: AccessDenied):
+ """The JSON error response for an :class:`AccessDenied`."""
+ return make_response(
+ jsonify({"success": False, "message": err.message}), err.status
+ )
diff --git a/docsgpt/api/user/sources/chunks.py b/docsgpt/api/user/sources/chunks.py
index 240ed571..d0185384 100644
--- a/docsgpt/api/user/sources/chunks.py
+++ b/docsgpt/api/user/sources/chunks.py
@@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
from docsgpt.api.user.base import get_vector_store
-from docsgpt.api.user.team_sharing import can_access, effective_write_owner
-from docsgpt.storage.db.repositories.sources import SourcesRepository
+from docsgpt.api.user.resource_access import AccessDenied
+from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.storage.db.session import db_readonly
from docsgpt.utils import check_required_fields, num_tokens_from_string
from docsgpt.vectorstore.base import InvalidChunkMetadataError
@@ -18,38 +18,27 @@ sources_chunks_ns = Namespace(
)
-def _resolve_source(doc_id: str, user: str):
- """Resolve a source (UUID or legacy ObjectId) the caller may READ.
+def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict:
+ """Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on.
- Read access = owner or any team grant (viewer/editor). Returns the row
- dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
+ ``use`` (browse chunks) is open to every role; ``edit`` (add / delete /
+ update chunks) needs owner or team editor. The vector partition is keyed
+ by source id, so a team editor's write needs no owner id.
+
+ Args:
+ doc_id: Source id from the request.
+ user: The caller's ``sub``.
+ action: ``use`` for reads, ``edit`` for chunk writes.
+
+ Returns:
+ dict: The source row (PG UUID in ``id``).
+
+ Raises:
+ AccessDenied: 404 when not visible, 403 when the role can't do it.
"""
with db_readonly() as conn:
- doc = SourcesRepository(conn).get_any(doc_id, user)
- if doc is not None:
- return doc
- if not can_access(conn, "source", doc_id, user):
- return None
- return SourcesRepository(conn).get_by_id(doc_id)
-
-
-def _resolve_source_for_write(doc_id: str, user: str):
- """Resolve a source the caller may WRITE chunks on.
-
- Returns the row dict when ``user`` owns the source, or when they hold a
- team ``editor`` grant (adding/removing/editing documents is editor-allowed
- — the vector partition is keyed by source_id, owner-agnostic). Returns
- ``None`` for viewer-only / no access. Source deletion stays owner-only and
- is handled elsewhere.
- """
- with db_readonly() as conn:
- doc = SourcesRepository(conn).get_any(doc_id, user)
- if doc is not None:
- return doc
- owner = effective_write_owner(conn, "source", doc_id, user)
- if not owner:
- return None
- return SourcesRepository(conn).get_any(doc_id, owner)
+ doc, _ra = load_source(conn, doc_id, user, action)
+ return doc
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
@@ -193,13 +182,11 @@ class GetChunks(Resource):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
try:
doc = _resolve_source(doc_id, user)
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
- if not doc:
- return make_response(
- jsonify({"error": "Document not found or access denied"}), 404
- )
resolved_id = str(doc["id"])
try:
store = get_vector_store(resolved_id)
@@ -278,12 +265,12 @@ class AddChunk(Resource):
metadata["token_count"] = token_count
try:
- doc = _resolve_source_for_write(doc_id, user)
+ doc = _resolve_source(doc_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
- if not doc:
- return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunk_id = store.add_chunk(text, metadata)
@@ -311,12 +298,12 @@ class DeleteChunk(Resource):
chunk_id = request.args.get("chunk_id")
try:
- doc = _resolve_source_for_write(doc_id, user)
+ doc = _resolve_source(doc_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
- if not doc:
- return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
deleted = store.delete_chunk(chunk_id)
@@ -378,12 +365,12 @@ class UpdateChunk(Resource):
metadata = {}
metadata["token_count"] = token_count
try:
- doc = _resolve_source_for_write(doc_id, user)
+ doc = _resolve_source(doc_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
- if not doc:
- return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
diff --git a/docsgpt/api/user/sources/retrieval_test.py b/docsgpt/api/user/sources/retrieval_test.py
index b26ca043..e1c0f812 100644
--- a/docsgpt/api/user/sources/retrieval_test.py
+++ b/docsgpt/api/user/sources/retrieval_test.py
@@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource
from pydantic import ValidationError
from docsgpt.api import api
-from docsgpt.api.user.sources.routes import _resolve_readable_source
+from docsgpt.api.user.resource_access import AccessDenied
+from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.core.model_utils import get_default_model_id
from docsgpt.retriever.dispatcher import Dispatcher
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -102,21 +103,16 @@ class SourceSearch(Resource):
# Read access = owner or any team grant (viewer included), matching
# the other source read endpoints (wiki pages, graph).
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
- # An unresolvable id yields None (→ 404); reaching here means the
+ # An unresolvable id is AccessDenied (404); reaching here means the
# lookup itself failed, which is ours, not the caller's.
logger.error(f"Error resolving source: {e}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Could not resolve source"}), 500
)
- if not doc:
- return make_response(
- jsonify(
- {"success": False, "message": "Source not found or access denied"}
- ),
- 404,
- )
resolved_id = str(doc["id"])
# A supplied config is validated exactly as strictly as a saved one (D7
diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py
index 9043e566..abbb0492 100644
--- a/docsgpt/api/user/sources/routes.py
+++ b/docsgpt/api/user/sources/routes.py
@@ -3,6 +3,7 @@
import json
import math
import uuid
+from typing import Optional
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
@@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import (
reingest_source_task,
sync_source,
)
-from docsgpt.api.user.team_sharing import (
- can_access,
- effective_write_owner,
- visible_with_access,
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ delete_settings,
+ payload_for,
+ settings_many,
)
+from docsgpt.api.user.sources.access import denied_response, load_source
+from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.core.settings import settings
from docsgpt.graphrag import graphrag_available
from docsgpt.parser.remote.remote_creator import normalize_remote_data
@@ -70,6 +74,28 @@ def _get_provider_from_remote_data(remote_data):
return None
+def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
+ """Add ``access`` + ``allowed_actions`` to a listed source row.
+
+ The source's behaviour ``config`` is dropped when the caller's role may
+ not ``view_config`` (a viewer when the owner turned
+ ``viewers_can_see_config`` off).
+
+ Args:
+ entry: The row as the list endpoint builds it.
+ access: ``owner`` / ``editor`` / ``viewer``.
+ switches: The source's owner switches (``settings_many`` output).
+
+ Returns:
+ dict: ``entry`` with the access payload merged in.
+ """
+ payload = payload_for("source", access, switches)
+ if "view_config" not in payload["allowed_actions"]:
+ entry.pop("config", None)
+ entry.update(payload)
+ return entry
+
+
@sources_ns.route("/sources")
class CombinedJson(Resource):
@api.doc(description="Provide JSON file with combined available indexes")
@@ -93,6 +119,7 @@ class CombinedJson(Resource):
team_shared = visible_with_access(conn, user, "source")
shared_ids = [sid for sid in team_shared if sid not in owned_ids]
shared_sources = repo.list_by_ids(shared_ids)
+ switches = settings_many(conn, "source", [*owned_ids, *shared_ids])
# list_for_user sorts by created_at DESC; legacy shape sorted by
# "date" DESC. Both are monotonic on creation so the ordering is
# equivalent for dev; re-sort defensively.
@@ -103,7 +130,7 @@ class CombinedJson(Resource):
def _source_entry(index, *, ownership="user", team_access=None):
provider = _get_provider_from_remote_data(index.get("remote_data"))
- return {
+ entry = {
"id": str(index["id"]),
"name": index.get("name"),
"date": index.get("date"),
@@ -121,6 +148,11 @@ class CombinedJson(Resource):
"ownership": ownership,
"team_access": team_access,
}
+ return _with_access(
+ entry,
+ "owner" if ownership == "user" else team_access,
+ switches.get(str(index["id"])),
+ )
for index in indexes:
data.append(_source_entry(index))
@@ -178,6 +210,9 @@ class PaginatedSources(Resource):
sort_order=sort_order,
extra_ids=extra_ids,
)
+ switches = settings_many(
+ conn, "source", [str(doc["id"]) for doc in window]
+ )
paginated_docs = []
for doc in window:
@@ -185,32 +220,37 @@ class PaginatedSources(Resource):
# Owner vs team-shared: a row in the window is the caller's own
# when its user_id matches; otherwise it arrived via extra_ids.
owned = str(doc.get("user_id")) == str(user)
+ entry = {
+ "id": str(doc["id"]),
+ "name": doc.get("name", ""),
+ "date": doc.get("date", ""),
+ "model": settings.EMBEDDINGS_NAME,
+ "location": "local",
+ "tokens": doc.get("tokens", ""),
+ "retriever": doc.get("retriever", "classic"),
+ "syncFrequency": doc.get("sync_frequency", ""),
+ "provider": provider,
+ "isNested": bool(doc.get("directory_structure")),
+ "type": doc.get("type", "file"),
+ # Lenient read (D7): always emit a fully-defaulted
+ # config so the edit modal can pre-fill, even for a
+ # legacy {} row.
+ "config": SourceConfig.parse(
+ doc.get("config")
+ ).model_dump(),
+ # Derived in SourcesRepository.list_for_user.
+ "ingestStatus": doc.get("ingest_status"),
+ "ownership": "user" if owned else "team",
+ "team_access": (
+ None if owned else team_shared.get(str(doc["id"]))
+ ),
+ }
paginated_docs.append(
- {
- "id": str(doc["id"]),
- "name": doc.get("name", ""),
- "date": doc.get("date", ""),
- "model": settings.EMBEDDINGS_NAME,
- "location": "local",
- "tokens": doc.get("tokens", ""),
- "retriever": doc.get("retriever", "classic"),
- "syncFrequency": doc.get("sync_frequency", ""),
- "provider": provider,
- "isNested": bool(doc.get("directory_structure")),
- "type": doc.get("type", "file"),
- # Lenient read (D7): always emit a fully-defaulted
- # config so the edit modal can pre-fill, even for a
- # legacy {} row.
- "config": SourceConfig.parse(
- doc.get("config")
- ).model_dump(),
- # Derived in SourcesRepository.list_for_user.
- "ingestStatus": doc.get("ingest_status"),
- "ownership": "user" if owned else "team",
- "team_access": (
- None if owned else team_shared.get(str(doc["id"]))
- ),
- }
+ _with_access(
+ entry,
+ "owner" if owned else team_shared.get(str(doc["id"])),
+ switches.get(str(doc["id"])),
+ )
)
response = {
"total": total_documents,
@@ -242,14 +282,17 @@ class DeleteOldIndexes(Resource):
return make_response(
jsonify({"success": False, "message": "Missing required fields"}), 400
)
+ # Owner-only unless the owner turned ``editors_can_delete`` on; the
+ # row is deleted as the owner either way.
try:
with db_readonly() as conn:
- doc = SourcesRepository(conn).get_any(source_id, user)
+ doc, ra = load_source(conn, source_id, user, "delete")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
- if not doc:
- return make_response(jsonify({"status": "not found"}), 404)
+ owner = ra.owner_id
storage = StorageCreator.get_storage()
resolved_id = str(doc["id"])
@@ -283,13 +326,17 @@ class DeleteOldIndexes(Resource):
return make_response(jsonify({"success": False}), 400)
try:
with db_session() as conn:
- SourcesRepository(conn).delete(resolved_id, user)
+ # The AFTER DELETE trigger drops the source's team grants; the
+ # owner switches have no FK, so clear them here.
+ SourcesRepository(conn).delete(resolved_id, owner)
+ delete_settings(conn, "source", resolved_id)
record_event(
conn,
"source.deleted",
actor=user,
source_id=resolved_id,
name=doc.get("name"),
+ owner=owner if owner != user else None,
)
except Exception as err:
current_app.logger.error(
@@ -342,21 +389,13 @@ class ManageSync(Resource):
)
try:
with db_session() as conn:
- repo = SourcesRepository(conn)
- doc = repo.get_any(source_id, user)
- if doc is not None:
- repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
- else:
- # Team editor write path (sync_frequency is metadata, no
- # ingestion side effects). Reingest/sync triggers stay
- # owner-only pending a cost/side-effect decision.
- owner = effective_write_owner(conn, "source", source_id, user)
- if not owner:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
- repo.update(source_id, owner, {"sync_frequency": sync_frequency})
+ # Owner or team editor; the write lands as the owner.
+ doc, ra = load_source(conn, source_id, user, "edit")
+ SourcesRepository(conn).update(
+ str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency}
+ )
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating sync frequency: {err}", exc_info=True
@@ -391,21 +430,15 @@ class SyncSource(Resource):
# source_id (owner-agnostic), so dispatching as the owner is correct.
try:
with db_readonly() as conn:
- doc = SourcesRepository(conn).get_any(source_id, user)
- owner = user
- if doc is None:
- owner = effective_write_owner(conn, "source", source_id, user)
- if owner:
- doc = SourcesRepository(conn).get_any(source_id, owner)
+ doc, ra = load_source(conn, source_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Invalid source ID"}), 400
)
- if not doc:
- return make_response(
- jsonify({"success": False, "message": "Source not accessible"}), 403
- )
+ owner = ra.owner_id
source_type = doc.get("type", "")
if source_type and source_type.startswith("connector"):
return make_response(
@@ -469,12 +502,9 @@ class ReingestSource(Resource):
# (owner-agnostic), so dispatching as the owner is correct.
try:
with db_readonly() as conn:
- doc = SourcesRepository(conn).get_any(source_id, user)
- owner = user
- if doc is None:
- owner = effective_write_owner(conn, "source", source_id, user)
- if owner:
- doc = SourcesRepository(conn).get_any(source_id, owner)
+ doc, ra = load_source(conn, source_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error looking up source: {err}", exc_info=True
@@ -482,10 +512,7 @@ class ReingestSource(Resource):
return make_response(
jsonify({"success": False, "message": "Invalid source ID"}), 400
)
- if not doc:
- return make_response(
- jsonify({"success": False, "message": "Source not accessible"}), 403
- )
+ owner = ra.owner_id
resolved_source_id = str(doc["id"])
# Drop the stale chunk-progress row so the sources list stops
# deriving a 'failed' status; reingest never rewrites it itself.
@@ -548,11 +575,7 @@ class DirectoryStructure(Resource):
return make_response(jsonify({"error": "Document ID is required"}), 400)
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, doc_id, user)
- if not doc:
- return make_response(
- jsonify({"error": "Document not found or access denied"}), 404
- )
+ doc, _ra = load_source(conn, doc_id, user, "use")
directory_structure = doc.get("directory_structure", {})
base_path = doc.get("file_path", "")
@@ -579,6 +602,8 @@ class DirectoryStructure(Resource):
),
200,
)
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as e:
current_app.logger.error(
f"Error retrieving directory structure: {e}", exc_info=True
@@ -636,23 +661,9 @@ class SourceConfigResource(Resource):
try:
with db_session() as conn:
repo = SourcesRepository(conn)
- # Resolve the owner to write AS: ``user`` when they own the
- # source, the real owner when ``user`` holds a team ``editor``
- # grant. A viewer / no-access resolves to None → 403.
- owner = effective_write_owner(conn, "source", source_id, user)
- if not owner:
- return make_response(
- jsonify(
- {"success": False, "message": "Source not accessible"}
- ),
- 403,
- )
- doc = repo.get_any(source_id, owner)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ # Owner or team editor; the write lands as the owner.
+ doc, ra = load_source(conn, source_id, user, "edit")
+ owner = ra.owner_id
# Ingest-time fields (config.chunking) only take effect after a
# re-ingest (D8); compare against the current config to decide.
current_config = SourceConfig.parse(doc.get("config"))
@@ -683,6 +694,8 @@ class SourceConfigResource(Resource):
repo.update(
str(doc["id"]), owner, {"config": new_config.model_dump()}
)
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating source config for {source_id}: {err}", exc_info=True
@@ -734,20 +747,6 @@ def _unsupported_retrieval_warnings(config) -> list:
return warnings
-def _resolve_readable_source(conn, source_id, user):
- """Return a source dict the caller may READ, or None.
-
- Read access = owner or any team grant (viewer/editor). Resolves the row
- without ownership scoping only after the grant check passes.
- """
- doc = SourcesRepository(conn).get_any(source_id, user)
- if doc is not None:
- return doc
- if not can_access(conn, "source", source_id, user):
- return None
- return SourcesRepository(conn).get_by_id(source_id)
-
-
def _wiki_page_node(page):
return {
"path": page.get("path"),
@@ -886,12 +885,10 @@ class WikiPages(Resource):
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ try:
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
pages = WikiPagesRepository(conn).list_for_source(str(doc["id"]))
directory_structure = doc.get("directory_structure") or {}
except Exception as err:
@@ -934,12 +931,10 @@ class WikiPage(Resource):
)
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ try:
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path)
except Exception as err:
current_app.logger.error(
@@ -977,20 +972,12 @@ class WikiPage(Resource):
expected_version = data.get("expected_version")
try:
with db_session() as conn:
- owner = effective_write_owner(conn, "source", source_id, user)
- if not owner:
- return make_response(
- jsonify(
- {"success": False, "message": "Source not accessible"}
- ),
- 403,
- )
- doc = SourcesRepository(conn).get_any(source_id, owner)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ # Owner or team editor; writes and re-embeds run as the owner.
+ try:
+ doc, ra = load_source(conn, source_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
+ owner = ra.owner_id
resolved_source_id = str(doc["id"])
try:
page = WikiPagesRepository(conn).upsert(
@@ -1074,20 +1061,12 @@ class ConvertSourceToWiki(Resource):
user = decoded_token.get("sub")
try:
with db_session() as conn:
- owner = effective_write_owner(conn, "source", source_id, user)
- if not owner:
- return make_response(
- jsonify(
- {"success": False, "message": "Source not accessible"}
- ),
- 403,
- )
- doc = SourcesRepository(conn).get_any(source_id, owner)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ # Owner or team editor; writes and re-embeds run as the owner.
+ try:
+ doc, ra = load_source(conn, source_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
+ owner = ra.owner_id
resolved_source_id = str(doc["id"])
# A mid-ingest source has an incomplete directory structure, so
# it could be mis-detected as blank and wrongly enabled inline.
@@ -1194,20 +1173,12 @@ class EnableSourceGraphRAG(Resource):
user = decoded_token.get("sub")
try:
with db_session() as conn:
- owner = effective_write_owner(conn, "source", source_id, user)
- if not owner:
- return make_response(
- jsonify(
- {"success": False, "message": "Source not accessible"}
- ),
- 403,
- )
- doc = SourcesRepository(conn).get_any(source_id, owner)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ # Owner or team editor; writes and re-embeds run as the owner.
+ try:
+ doc, ra = load_source(conn, source_id, user, "edit")
+ except AccessDenied as err:
+ return denied_response(err)
+ owner = ra.owner_id
resolved_source_id = str(doc["id"])
cfg = SourceConfig.parse(doc.get("config"))
repo = SourcesRepository(conn)
@@ -1314,12 +1285,10 @@ class SourceGraph(Resource):
limit = None
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ try:
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
@@ -1449,12 +1418,10 @@ class SourceGraphNodes(Resource):
type_key = request.args.get("type")
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ try:
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
@@ -1500,12 +1467,10 @@ class SourceGraphNode(Resource):
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
- doc = _resolve_readable_source(conn, source_id, user)
- if doc is None:
- return make_response(
- jsonify({"success": False, "message": "Source not found"}),
- 404,
- )
+ try:
+ doc, _ra = load_source(conn, source_id, user, "use")
+ except AccessDenied as err:
+ return denied_response(err)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
diff --git a/docsgpt/api/user/sources/upload.py b/docsgpt/api/user/sources/upload.py
index f15bee65..4fc52e7e 100644
--- a/docsgpt/api/user/sources/upload.py
+++ b/docsgpt/api/user/sources/upload.py
@@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text
from docsgpt.api import api
from docsgpt.api.audit import record_event
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
-from docsgpt.api.user.team_sharing import effective_write_owner
+from docsgpt.api.user.resource_access import AccessDenied
+from docsgpt.api.user.sources.access import denied_response, load_source
from docsgpt.core.settings import settings
from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
@@ -721,22 +722,10 @@ class ManageSourceFiles(Resource):
# (owner-agnostic), so running the ops as the owner is correct.
try:
with db_readonly() as conn:
- source = SourcesRepository(conn).get_any(source_id, user)
- owner = user
- if source is None:
- owner = effective_write_owner(conn, "source", source_id, user)
- if owner:
- source = SourcesRepository(conn).get_any(source_id, owner)
- if not source:
- return make_response(
- jsonify(
- {
- "success": False,
- "message": "Source not found or access denied",
- }
- ),
- 404,
- )
+ source, ra = load_source(conn, source_id, user, "edit")
+ owner = ra.owner_id
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
return make_response(
diff --git a/docsgpt/api/user/team_sharing.py b/docsgpt/api/user/team_sharing.py
index fb2b8ccf..56e5b821 100644
--- a/docsgpt/api/user/team_sharing.py
+++ b/docsgpt/api/user/team_sharing.py
@@ -13,13 +13,10 @@ from typing import Optional
from sqlalchemy import Connection
-from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.api.user.resource_access import resolve
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
-from docsgpt.storage.db.repositories.team_resource_grants import (
- TeamResourceGrantsRepository,
-)
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
@@ -89,22 +86,14 @@ def effective_write_owner(
...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without
a separate ownerless write path. None means viewer-only or no access → the
route should answer 403/404. Delete is never authorized here — owner-only.
+
+ A thin wrapper over :func:`resource_access.resolve`; new code should call
+ ``resource_access.require`` with a specific action instead.
"""
- if owns_resource(conn, resource_type, resource_id, user_id):
- return user_id
- # Past the ownership check, only canonical-UUID resources can carry a team
- # grant; a legacy/non-UUID id can't, and casting it would poison the txn.
- if not looks_like_uuid(resource_id):
+ ra = resolve(conn, resource_type, resource_id, user_id)
+ if ra is None or ra.access not in ("owner", "editor"):
return None
- grants = TeamResourceGrantsRepository(conn).list_for_resource(
- resource_type, resource_id
- )
- if not grants:
- return None
- if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id):
- # All grant rows carry the same denormalised owner_id.
- return grants[0].get("owner_id")
- return None
+ return ra.owner_id
def can_access(
@@ -116,9 +105,9 @@ def can_access(
``source_id`` to an agent): you may reference what you own or what a team has
shared with you directly. Transitive access *through* a shared agent is a
separate, run-time concept and is intentionally NOT gated here.
+
+ A thin wrapper over :func:`resource_access.resolve`.
"""
if not resource_id:
return True
- if owns_resource(conn, resource_type, resource_id, user_id):
- return True
- return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id)
+ return resolve(conn, resource_type, resource_id, user_id) is not None
diff --git a/docsgpt/api/user/teams/routes.py b/docsgpt/api/user/teams/routes.py
index df51ec0e..adfd5374 100644
--- a/docsgpt/api/user/teams/routes.py
+++ b/docsgpt/api/user/teams/routes.py
@@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``):
- Team detail / member list / grant list require team membership.
- Member management and team edit require ``team_admin``.
- Team deletion and owner transfer are owner-only (a global ``admin`` overrides).
-- Sharing a resource requires the caller to OWN it (dispatched by resource_type)
- and be a member of the target team. Sharing is additive visibility — the
- resource's owner is never changed.
+- Sharing a resource requires the ``share`` action on it (the owner, or an
+ editor when the owner turned on ``editors_can_share``; see
+ ``resource_access.py``) and membership of the target team. Unsharing needs
+ ``share`` (no membership required) or ``team_admin`` of the team. Sharing is
+ additive visibility — the resource's owner is never changed.
+- The team owner can't be demoted or removed; they transfer ownership first.
``team_id`` always comes from the URL path (never the body) — enforced by
``require_team_role`` and by reading ``team_id`` as a route kwarg here.
@@ -22,14 +25,25 @@ import uuid
from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
+from sqlalchemy import text
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
+from docsgpt.api.user.resource_access import (
+ RESOURCE_TYPES,
+ AccessDenied,
+ ResourceAccess,
+ build,
+ public_settings,
+ require,
+ set_settings,
+ settings_many,
+)
from docsgpt.api.user.team_authz import (
has_team_role,
team_admin_required,
team_member_required,
)
-from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource
+from docsgpt.api.user.team_sharing import is_valid_resource_type
from docsgpt.events.publisher import publish_user_event
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
@@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import (
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
+from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.teams import TeamsRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
@@ -62,6 +77,99 @@ def _current_user() -> str | None:
return token.get("sub") if isinstance(token, dict) else None
+def _denied(err: AccessDenied):
+ """JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed)."""
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
+
+
+def _team_payload(team: dict, user: str | None) -> dict:
+ """Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer)."""
+ team["is_owner"] = bool(user) and team.get("owner_id") == user
+ return team
+
+
+# Name + owner of each shareable resource, looked up unscoped by id (the grant
+# row already proves it was shared; the caller's own access is computed below).
+_RESOURCE_ROW_SQL = {
+ "agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))",
+ "source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
+ "prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
+ "tool": (
+ "SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, "
+ "user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
+ ),
+}
+
+
+def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]:
+ """``(type, id) -> {name, user_id}`` for every resource the grants point at."""
+ ids_by_type: dict[str, set[str]] = {}
+ for g in grants:
+ ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"]))
+ out: dict[tuple[str, str], dict] = {}
+ for rtype, ids in ids_by_type.items():
+ sql = _RESOURCE_ROW_SQL.get(rtype)
+ if not sql:
+ continue
+ for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall():
+ out[(rtype, str(rid))] = {"name": name, "user_id": owner}
+ return out
+
+
+def _caller_access(
+ conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict]
+) -> dict[tuple[str, str], ResourceAccess]:
+ """The caller's live access to each granted resource, in a few bulk queries.
+
+ Same answer as ``resource_access.resolve`` per resource (owner by the
+ resource's ``user_id``, else the strongest team grant reaching the caller),
+ without four queries per row.
+ """
+ scope = TeamScopeRepository(conn)
+ out: dict[tuple[str, str], ResourceAccess] = {}
+ for rtype in {g["resource_type"] for g in grants}:
+ ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype})
+ via_teams = scope.visible_with_access(user, rtype)
+ settings = settings_many(conn, rtype, ids)
+ for rid in ids:
+ row = rows.get((rtype, rid))
+ if row is None:
+ continue # dangling grant: the resource is gone
+ if row["user_id"] == user:
+ level = "owner"
+ else:
+ level = via_teams.get(rid)
+ if level is None:
+ continue
+ out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid])
+ return out
+
+
+def _user_labels(conn, user_ids: set[str]) -> dict[str, str]:
+ """``user_id -> email`` for the users on file with an email."""
+ ids = [u for u in user_ids if u]
+ if not ids:
+ return {}
+ rows = conn.execute(
+ text(
+ "SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
+ "AND email IS NOT NULL AND email <> ''"
+ ),
+ {"ids": ids},
+ ).fetchall()
+ return {uid: email for uid, email in rows}
+
+
+def _valid_resource(resource_type, resource_id) -> bool:
+ """A known shareable type and a canonical-UUID id (grants are UUID-only)."""
+ return (
+ is_valid_resource_type(resource_type)
+ and bool(resource_id)
+ and isinstance(resource_id, str)
+ and looks_like_uuid(resource_id)
+ )
+
+
# Metadata keys naming the user a team event acted on, most specific first.
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
@@ -218,6 +326,7 @@ class Teams(Resource):
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_for_user(user)
+ teams = [_team_payload(t, user) for t in teams]
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List teams failed: %s", err, exc_info=True)
@@ -243,6 +352,7 @@ class Teams(Resource):
)
_audit(conn, user, "team.create", team_id=team["id"], name=name)
team["member_role"] = ROLE_TEAM_ADMIN
+ _team_payload(team, user)
return make_response(jsonify({"success": True, "team": team}), 201)
except Exception as err:
logger.error("Create team failed: %s", err, exc_info=True)
@@ -263,6 +373,7 @@ class Team(Resource):
members = TeamMembersRepository(conn)
team["members"] = members.list_members(team_id)
team["member_role"] = members.role_for(user, team_id)
+ _team_payload(team, user)
return make_response(jsonify({"success": True, "team": team}), 200)
except Exception as err:
logger.error("Get team failed: %s", err, exc_info=True)
@@ -370,6 +481,10 @@ class TeamMember(Resource):
return {"success": False, "message": "invalid role"}, 400
try:
with db_session() as conn:
+ if role == ROLE_TEAM_MEMBER:
+ blocked = self._owner_guard(conn, team_id, member_id, "demote")
+ if blocked is not None:
+ return blocked
members = TeamMembersRepository(conn)
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
members, team_id, member_id
@@ -403,6 +518,9 @@ class TeamMember(Resource):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_session() as conn:
+ blocked = self._owner_guard(conn, team_id, member_id, "remove")
+ if blocked is not None:
+ return blocked
members = TeamMembersRepository(conn)
if self._would_orphan_admins(members, team_id, member_id):
return {
@@ -423,6 +541,38 @@ class TeamMember(Resource):
logger.error("Remove member failed: %s", err, exc_info=True)
return {"success": False}, 400
+ @staticmethod
+ def _owner_guard(conn, team_id: str, member_id: str, change: str):
+ """Refuse to demote or remove the team owner.
+
+ Another admin gets 403; the owner themselves gets 400 telling them to
+ transfer ownership first (the team would otherwise have an owner who
+ isn't an admin, or isn't a member at all).
+
+ Args:
+ conn: Open connection.
+ team_id: Team from the URL path.
+ member_id: The member being changed.
+ change: ``"demote"`` or ``"remove"`` (for the message).
+
+ Returns:
+ A response to return, or None when the change may proceed.
+ """
+ team = TeamsRepository(conn).get(team_id)
+ if not team or team.get("owner_id") != member_id:
+ return None
+ if member_id == _current_user():
+ message = (
+ "Transfer team ownership to another member before you leave the team"
+ if change == "remove"
+ else "Transfer team ownership to another member before you step down as admin"
+ )
+ return make_response(jsonify({"success": False, "message": message}), 400)
+ verb = "removed" if change == "remove" else "demoted"
+ return make_response(
+ jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403
+ )
+
@staticmethod
def _would_orphan_admins(
members: TeamMembersRepository, team_id: str, member_id: str
@@ -443,21 +593,66 @@ class TeamMember(Resource):
class TeamGrants(Resource):
@team_member_required
def get(self, team_id):
- """List resources shared with this team. Requires membership."""
+ """List resources shared with this team. Requires membership.
+
+ Each row carries the resource's name, owner and people labels (email
+ when on file, else null) and ``caller`` — the caller's own live
+ ``{access, allowed_actions}`` on that resource (null if none). A plain
+ member sees whole-team grants and grants aimed at them; a team_admin,
+ or someone with ``share`` on the resource, sees every grant.
+ """
+ user = _current_user()
+ token = getattr(request, "decoded_token", None)
resource_type = request.args.get("resource_type")
try:
with db_readonly() as conn:
grants = TeamResourceGrantsRepository(conn).list_for_team(
team_id, resource_type
)
- return make_response(jsonify({"success": True, "grants": grants}), 200)
+ team_role = TeamMembersRepository(conn).role_for(user, team_id)
+ sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN)
+ rows = _resource_rows(conn, grants)
+ access = _caller_access(conn, user, grants, rows)
+ visible = []
+ for g in grants:
+ key = (g["resource_type"], str(g["resource_id"]))
+ ra = access.get(key)
+ target = g.get("target_user_id")
+ if not (sees_all or not target or target == user or (ra and ra.can("share"))):
+ continue
+ row = rows.get(key) or {}
+ g["resource_name"] = row.get("name")
+ g["owner_id"] = row.get("user_id") or g.get("owner_id")
+ g["caller"] = ra.payload() if ra else None
+ visible.append(g)
+ labels = _user_labels(
+ conn,
+ {
+ u
+ for g in visible
+ for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by"))
+ if u
+ },
+ )
+ for g in visible:
+ g["owner_label"] = labels.get(g.get("owner_id"))
+ g["target_user_label"] = labels.get(g.get("target_user_id"))
+ g["granted_by_label"] = labels.get(g.get("granted_by"))
+ return make_response(
+ jsonify({"success": True, "grants": visible, "team_role": team_role}), 200
+ )
except Exception as err:
logger.error("List grants failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_member_required
def post(self, team_id):
- """Share a resource the caller OWNS with this team (additive visibility)."""
+ """Share a resource with this team, or change an existing grant's level.
+
+ Needs ``share`` on the resource (the owner, or an editor when the owner
+ turned on ``editors_can_share``) and membership of the team. The grant
+ records the real owner as ``owner_id`` and the caller as ``granted_by``.
+ """
user = _current_user()
data = request.get_json(silent=True) or {}
resource_type = data.get("resource_type")
@@ -465,20 +660,18 @@ class TeamGrants(Resource):
access_level = data.get("access_level", "viewer")
# None → share with the whole team; a sub → share with that one member.
target_user_id = (data.get("target_user_id") or "").strip() or None
- if (
- not is_valid_resource_type(resource_type)
- or not resource_id
- or not looks_like_uuid(resource_id)
- ):
+ if not _valid_resource(resource_type, resource_id):
return {"success": False, "message": "invalid resource"}, 400
if access_level not in _VALID_ACCESS_LEVELS:
return {"success": False, "message": "invalid access_level"}, 400
try:
with db_session() as conn:
- # Ownership is the security boundary: dispatch by resource_type so
- # a mismatched type/id can't register a bogus grant.
- if not owns_resource(conn, resource_type, resource_id, user):
- return {"success": False, "message": "Not the resource owner"}, 403
+ # ``require`` dispatches by resource_type, so a mismatched
+ # type/id can't register a bogus grant (the table has no FK).
+ try:
+ ra = require(conn, resource_type, resource_id, user, "share")
+ except AccessDenied as denied:
+ return _denied(denied)
# A per-member share target must actually be a member of the team.
if target_user_id and not TeamMembersRepository(conn).is_member(
target_user_id, team_id
@@ -487,8 +680,8 @@ class TeamGrants(Resource):
grant = TeamResourceGrantsRepository(conn).grant(
team_id,
resource_type,
- resource_id,
- owner_id=user,
+ ra.resource_id,
+ owner_id=ra.owner_id,
granted_by=user,
access_level=access_level,
target_user_id=target_user_id,
@@ -512,15 +705,21 @@ class TeamGrants(Resource):
logger.error("Share resource failed: %s", err, exc_info=True)
return {"success": False}, 400
- @team_member_required
def delete(self, team_id):
- """Unshare a resource. Allowed for the resource owner or a team_admin.
+ """Unshare a resource from this team.
- Identifiers come from query params (some proxies strip DELETE bodies),
- with a JSON-body fallback for older clients.
+ Allowed with ``share`` on the resource — no membership needed, so an
+ owner who left the team can still pull their resource back — or for a
+ team_admin of this team. ``target_user_id`` picks one member's grant
+ (absent → the whole-team grant). Identifiers come from query params
+ (some proxies strip DELETE bodies), with a JSON-body fallback.
"""
user = _current_user()
token = getattr(request, "decoded_token", None)
+ if not user:
+ return make_response(
+ jsonify({"success": False, "message": "Authentication required"}), 401
+ )
data = request.get_json(silent=True) or {}
resource_type = request.args.get("resource_type") or data.get("resource_type")
resource_id = request.args.get("resource_id") or data.get("resource_id")
@@ -528,17 +727,15 @@ class TeamGrants(Resource):
target_user_id = (
request.args.get("target_user_id") or data.get("target_user_id") or ""
).strip() or None
- if (
- not is_valid_resource_type(resource_type)
- or not resource_id
- or not looks_like_uuid(resource_id)
- ):
+ if not _valid_resource(resource_type, resource_id):
return {"success": False, "message": "invalid resource"}, 400
try:
with db_session() as conn:
- is_owner = owns_resource(conn, resource_type, resource_id, user)
- if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
- return {"success": False, "message": "Forbidden"}, 403
+ try:
+ require(conn, resource_type, resource_id, user, "share")
+ except AccessDenied:
+ if not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
+ return {"success": False, "message": "Forbidden"}, 403
revoked = TeamResourceGrantsRepository(conn).revoke(
team_id, resource_type, resource_id, target_user_id=target_user_id
)
@@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource):
@teams_ns.route("/resource_shares")
class ResourceShares(Resource):
def get(self):
- """List the teams a resource the caller OWNS is shared with.
+ """List the teams a resource is shared with. Needs ``share`` on it.
- Powers the share dialog (show current shares + unshare). Owner-only so a
- non-owner can't enumerate a resource's sharing graph.
+ Powers the share dialog (current shares + unshare), so only someone who
+ may change the sharing can enumerate it: 404 when the resource isn't
+ visible, 403 when the caller's role can't share.
"""
user = _current_user()
if not user:
return {"success": False}, 401
resource_type = request.args.get("resource_type")
resource_id = request.args.get("resource_id")
- if (
- not is_valid_resource_type(resource_type)
- or not resource_id
- or not looks_like_uuid(resource_id)
- ):
+ if not _valid_resource(resource_type, resource_id):
return {"success": False, "message": "invalid resource"}, 400
try:
with db_readonly() as conn:
- if not owns_resource(conn, resource_type, resource_id, user):
- return {"success": False, "message": "Not the resource owner"}, 403
+ try:
+ require(conn, resource_type, resource_id, user, "share")
+ except AccessDenied as denied:
+ return _denied(denied)
shares = TeamResourceGrantsRepository(conn).list_for_resource(
resource_type, resource_id
)
@@ -631,6 +827,83 @@ class ResourceShares(Resource):
return {"success": False}, 400
+def _settings_response(ra: ResourceAccess):
+ """The ``resource_settings`` body: switches plus the caller's access."""
+ return make_response(
+ jsonify(
+ {
+ "success": True,
+ "resource_type": ra.resource_type,
+ "resource_id": ra.resource_id,
+ "settings": public_settings(ra.resource_type, ra.settings),
+ **ra.payload(),
+ }
+ ),
+ 200,
+ )
+
+
+@teams_ns.route("/resource_settings")
+class ResourceSettings(Resource):
+ def get(self):
+ """A resource's sharing switches. Anyone with access may read them.
+
+ Query: ``resource_type``, ``resource_id``. Returns ``settings`` as
+ ``[{key, value, default}]`` in display order, plus the caller's
+ ``access`` and ``allowed_actions``.
+ """
+ user = _current_user()
+ if not user:
+ return {"success": False}, 401
+ resource_type = request.args.get("resource_type")
+ resource_id = request.args.get("resource_id")
+ if resource_type not in RESOURCE_TYPES or not resource_id:
+ return {"success": False, "message": "invalid resource"}, 400
+ try:
+ with db_readonly() as conn:
+ try:
+ ra = require(conn, resource_type, resource_id, user, "use")
+ except AccessDenied as denied:
+ return _denied(denied)
+ return _settings_response(ra)
+ except Exception as err:
+ logger.error("Get resource settings failed: %s", err, exc_info=True)
+ return {"success": False}, 400
+
+ def put(self):
+ """Change a resource's sharing switches. Needs ``manage_settings`` (owner).
+
+ Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``.
+ An unknown key or a non-boolean value is a 400. Returns the GET shape.
+ """
+ user = _current_user()
+ if not user:
+ return {"success": False}, 401
+ data = request.get_json(silent=True) or {}
+ resource_type = data.get("resource_type")
+ resource_id = data.get("resource_id")
+ changes = data.get("settings")
+ if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str):
+ return {"success": False, "message": "invalid resource"}, 400
+ if not isinstance(changes, dict):
+ return {"success": False, "message": "settings must be an object"}, 400
+ try:
+ with db_session() as conn:
+ try:
+ ra = require(conn, resource_type, resource_id, user, "manage_settings")
+ except AccessDenied as denied:
+ return _denied(denied)
+ try:
+ merged = set_settings(conn, resource_type, ra.resource_id, changes, user)
+ except ValueError as bad:
+ return {"success": False, "message": str(bad)}, 400
+ updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged)
+ return _settings_response(updated)
+ except Exception as err:
+ logger.error("Update resource settings failed: %s", err, exc_info=True)
+ return {"success": False}, 400
+
+
@teams_ns.route("/admin/teams")
class AllTeams(Resource):
method_decorators = []
diff --git a/docsgpt/api/user/tools/mcp.py b/docsgpt/api/user/tools/mcp.py
index bcc99084..bdc9281b 100644
--- a/docsgpt/api/user/tools/mcp.py
+++ b/docsgpt/api/user/tools/mcp.py
@@ -7,7 +7,15 @@ from flask_restx import Namespace, Resource, fields
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
from docsgpt.api import api
-from docsgpt.api.user.tools.routes import transform_actions
+from docsgpt.api.user.resource_access import AccessDenied, require
+from docsgpt.api.user.team_sharing import visible_with_access
+from docsgpt.api.user.tools.routes import (
+ _CREDENTIALS_FOR_NEW_SERVER,
+ _MCP_CREDENTIAL_AUTH_TYPES,
+ _mcp_host_changed,
+ denied_response,
+ transform_actions,
+)
from docsgpt.cache import get_redis_instance
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
@@ -75,12 +83,60 @@ def _validate_mcp_server_url(config: dict) -> None:
raise ValueError(f"Invalid server URL: {exc}") from exc
+_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
+
+
+def _existing_mcp_context(tool_id, user, config):
+ """Resolve the stored MCP tool a test/save refers to, and its credentials.
+
+ With no ``tool_id`` the caller acts on their own new server. With one,
+ the caller needs ``edit_credentials`` on that tool and everything runs as
+ its owner. Stored secrets are write-only, so an empty secret field reuses
+ the stored one while the host is unchanged; a new host never inherits them.
+
+ Returns:
+ ``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
+ response (404 / 400) to return as is.
+
+ Raises:
+ AccessDenied: the caller can't see the tool (404) or can't change
+ its credentials (403).
+ """
+ auth_credentials = _extract_auth_credentials(config)
+ if not tool_id:
+ return None, user, True, False, auth_credentials
+ with db_readonly() as conn:
+ ra = require(conn, "tool", tool_id, user, "edit_credentials")
+ existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
+ if not existing_doc or existing_doc.get("name") != "mcp_tool":
+ return make_response(
+ jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
+ )
+ existing_config = existing_doc.get("config") or {}
+ moved = _mcp_host_changed(config, existing_config)
+ auth_type = config.get("auth_type", "none")
+ new_secret_keys = set(auth_credentials) - {"api_key_header"}
+ if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
+ return make_response(
+ jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400
+ )
+ credentials = dict(auth_credentials)
+ existing_encrypted = None if moved else existing_config.get("encrypted_credentials")
+ if existing_encrypted:
+ credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **auth_credentials}
+ return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials
+
+
@tools_mcp_ns.route("/mcp_server/test")
class TestMCPServerConfig(Resource):
@api.expect(
api.model(
"MCPServerTestModel",
{
+ "id": fields.String(
+ required=False,
+ description="Stored tool to test with (empty secrets reuse its stored ones)",
+ ),
"config": fields.Raw(
required=True, description="MCP server configuration to test"
),
@@ -111,11 +167,20 @@ class TestMCPServerConfig(Resource):
_validate_mcp_server_url(config)
- auth_credentials = _extract_auth_credentials(config)
+ ctx = _existing_mcp_context(data.get("id"), user, config)
+ if not isinstance(ctx, tuple):
+ return ctx
+ _existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
+ if not is_owner and config.get("auth_type") == "oauth":
+ # An OAuth flow would store tokens under the editor's account
+ # (and its popup event goes to that account), not the owner's.
+ return make_response(
+ jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
+ )
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
- mcp_tool = MCPTool(config=test_config, user_id=user)
+ mcp_tool = MCPTool(config=test_config, user_id=owner_id)
result = mcp_tool.test_connection()
if result.get("requires_oauth"):
@@ -148,6 +213,8 @@ class TestMCPServerConfig(Resource):
"tools": result.get("tools", []),
}
return make_response(jsonify(safe_result), 200)
+ except AccessDenied as e:
+ return denied_response(e)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server test request: {e}")
return make_response(
@@ -207,13 +274,55 @@ class MCPServerSave(Resource):
_validate_mcp_server_url(config)
- auth_credentials = _extract_auth_credentials(config)
+ # An existing id is always an update of THAT row, written as its
+ # owner; it never falls through to creating a copy for the caller.
+ ctx = _existing_mcp_context(data.get("id"), user, config)
+ if not isinstance(ctx, tuple):
+ return ctx
+ existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
+ existing_config = (existing_doc or {}).get("config") or {}
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
- mcp_config["auth_credentials"] = auth_credentials
+ mcp_config["auth_credentials"] = merged_credentials
+ keep_actions = False
if auth_type == "oauth":
- if not config.get("oauth_task_id"):
+ if config.get("oauth_task_id"):
+ if not is_owner:
+ # The OAuth flow stores tokens under the account that
+ # ran it; reconnecting as the owner is owner-only.
+ return make_response(
+ jsonify({
+ "success": False,
+ "message": _ONLY_OWNER_RECONNECTS,
+ }),
+ 403,
+ )
+ redis_client = get_redis_instance()
+ manager = MCPOAuthManager(redis_client)
+ result = manager.get_oauth_status(
+ config["oauth_task_id"], user
+ )
+ if not result.get("status") == "completed":
+ return make_response(
+ jsonify(
+ {
+ "success": False,
+ "error": "OAuth failed or not completed. Please try authorizing again.",
+ }
+ ),
+ 400,
+ )
+ actions_metadata = result.get("tools", [])
+ elif (
+ existing_doc is not None
+ and not moved
+ and existing_config.get("auth_type") == "oauth"
+ ):
+ # Editing an already-connected server: keep its tools.
+ actions_metadata = existing_doc.get("actions") or []
+ keep_actions = True
+ else:
return make_response(
jsonify(
{
@@ -223,24 +332,8 @@ class MCPServerSave(Resource):
),
400,
)
- redis_client = get_redis_instance()
- manager = MCPOAuthManager(redis_client)
- result = manager.get_oauth_status(
- config["oauth_task_id"], user
- )
- if not result.get("status") == "completed":
- return make_response(
- jsonify(
- {
- "success": False,
- "error": "OAuth failed or not completed. Please try authorizing again.",
- }
- ),
- 400,
- )
- actions_metadata = result.get("tools", [])
- elif auth_type == "none" or auth_credentials:
- mcp_tool = MCPTool(config=mcp_config, user_id=user)
+ elif auth_type == "none" or merged_credentials:
+ mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
actions_metadata = mcp_tool.get_actions_metadata()
else:
@@ -249,30 +342,10 @@ class MCPServerSave(Resource):
)
storage_config = config.copy()
- tool_id = data.get("id")
- existing_doc = None
- existing_encrypted = None
- if tool_id:
- with db_readonly() as conn:
- repo = UserToolsRepository(conn)
- existing_doc = repo.get_any(tool_id, user)
- if existing_doc and existing_doc.get("name") == "mcp_tool":
- existing_encrypted = (existing_doc.get("config") or {}).get(
- "encrypted_credentials"
- )
- else:
- existing_doc = None
-
- if auth_credentials:
- if existing_encrypted:
- existing_secrets = decrypt_credentials(existing_encrypted, user)
- existing_secrets.update(auth_credentials)
- auth_credentials = existing_secrets
+ if merged_credentials:
storage_config["encrypted_credentials"] = encrypt_credentials(
- auth_credentials, user
+ merged_credentials, owner_id
)
- elif existing_encrypted:
- storage_config["encrypted_credentials"] = existing_encrypted
for field in [
"api_key",
@@ -283,58 +356,54 @@ class MCPServerSave(Resource):
"redirect_uri",
]:
storage_config.pop(field, None)
- transformed_actions = transform_actions(actions_metadata)
+ # Kept actions already carry the owner's on/off and approval flags.
+ transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
display_name = data["displayName"]
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
status_bool = bool(data.get("status", True))
+ fields_out = {
+ "display_name": display_name,
+ "custom_name": display_name,
+ "description": description,
+ "config": storage_config,
+ "actions": transformed_actions,
+ }
+ updated_message = (
+ f"MCP server updated successfully! Discovered {len(transformed_actions)} tools."
+ )
with db_session() as conn:
repo = UserToolsRepository(conn)
- if existing_doc:
- repo.update(
- str(existing_doc["id"]), user,
- {
- "display_name": display_name,
- "custom_name": display_name,
- "description": description,
- "config": storage_config,
- "actions": transformed_actions,
- "status": status_bool,
- },
- )
+ if existing_doc is not None:
+ # ``status`` is the owner's own chat switch; an editor's
+ # save doesn't flip it.
+ if is_owner:
+ fields_out["status"] = status_bool
+ repo.update(str(existing_doc["id"]), owner_id, fields_out)
saved_id = str(existing_doc["id"])
response_data = {
"success": True,
"id": saved_id,
- "message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
+ "message": updated_message,
"tools_count": len(transformed_actions),
}
else:
+ fields_out["status"] = status_bool
# Fall back to find_by_user_and_name — the original
# dual-write path also ran an existence check before
# deciding between insert and update.
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
- if tool_id is None and existing_by_name and (
+ if existing_by_name and (
(existing_by_name.get("config") or {}).get("server_url")
== storage_config.get("server_url")
):
- repo.update(
- str(existing_by_name["id"]), user,
- {
- "display_name": display_name,
- "custom_name": display_name,
- "description": description,
- "config": storage_config,
- "actions": transformed_actions,
- "status": status_bool,
- },
- )
+ repo.update(str(existing_by_name["id"]), user, fields_out)
saved_id = str(existing_by_name["id"])
response_data = {
"success": True,
"id": saved_id,
- "message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
+ "message": updated_message,
"tools_count": len(transformed_actions),
}
else:
@@ -355,18 +424,9 @@ class MCPServerSave(Resource):
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
- if tool_id and existing_doc is None:
- # Client requested update on a non-existent tool id.
- return make_response(
- jsonify(
- {
- "success": False,
- "error": "Tool not found or access denied",
- }
- ),
- 404,
- )
return make_response(jsonify(response_data), 200)
+ except AccessDenied as e:
+ return denied_response(e)
except ValueError as e:
current_app.logger.warning(f"Invalid MCP server save request: {e}")
return make_response(
@@ -455,6 +515,13 @@ class MCPAuthStatus(Resource):
tools_repo = UserToolsRepository(conn)
sessions_repo = ConnectorSessionsRepository(conn)
all_tools = tools_repo.list_for_user(user)
+ owned_ids = {str(t["id"]) for t in all_tools}
+ # Team-shared MCP servers the caller can see run with the
+ # owner's connection, so their status is the owner's.
+ shared_ids = [
+ tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids
+ ]
+ all_tools = all_tools + tools_repo.list_by_ids(shared_ids)
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
if not mcp_tools:
return make_response(
@@ -472,7 +539,7 @@ class MCPAuthStatus(Resource):
if server_url:
parsed = urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
- oauth_server_urls[tool_id] = base_url
+ oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url)
else:
statuses[tool_id] = "needs_auth"
else:
@@ -484,9 +551,9 @@ class MCPAuthStatus(Resource):
# and the URL in ``server_url``; reuse the repo's
# per-URL accessor rather than an ad-hoc $in query.
url_has_tokens: dict = {}
- for base_url in set(oauth_server_urls.values()):
+ for owner_id, base_url in set(oauth_server_urls.values()):
session = sessions_repo.get_by_user_and_server_url(
- user, base_url,
+ owner_id, base_url,
)
tokens = (
(session or {}).get("session_data", {}) or {}
@@ -494,13 +561,13 @@ class MCPAuthStatus(Resource):
# MCP code also stashes tokens into token_info on
# the row; consider either present as "connected".
token_info = (session or {}).get("token_info") or {}
- url_has_tokens[base_url] = bool(
+ url_has_tokens[(owner_id, base_url)] = bool(
tokens.get("access_token")
or token_info.get("access_token")
)
- for tool_id, base_url in oauth_server_urls.items():
- if url_has_tokens.get(base_url):
+ for tool_id, key in oauth_server_urls.items():
+ if url_has_tokens.get(key):
statuses[tool_id] = "connected"
else:
statuses[tool_id] = "needs_auth"
diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py
index ddc56916..72cbd6c3 100644
--- a/docsgpt/api/user/tools/routes.py
+++ b/docsgpt/api/user/tools/routes.py
@@ -1,7 +1,12 @@
"""Tool management routes."""
+import copy
+from typing import Any, Optional
+from urllib.parse import urlparse
+
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
+from sqlalchemy import Connection, text
from docsgpt.agents.default_tools import (
builtin_agent_tools_for_management,
@@ -13,12 +18,21 @@ from docsgpt.agents.default_tools import (
is_synthesized_tool_id,
WORKFLOW_ONLY_BUILTINS,
)
+from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY
from docsgpt.agents.tools.spec_parser import parse_spec
from docsgpt.agents.tools.tool_manager import ToolManager
from docsgpt.api import api
from docsgpt.api.pat.rules import filter_listing
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
-from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ delete_settings,
+ payload_for,
+ require,
+ ResourceAccess,
+ settings_many,
+)
+from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.core.settings import settings
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
@@ -26,6 +40,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
from docsgpt.storage.db.repositories.notes import NotesRepository
from docsgpt.storage.db.repositories.todos import TodosRepository
+from docsgpt.storage.db.repositories.user_tool_preferences import (
+ UserToolPreferencesRepository,
+)
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
from docsgpt.storage.db.session import db_readonly, db_session
@@ -166,6 +183,238 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u
return storage_config
+# ---------------------------------------------------------------------------
+# Access + secrets helpers
+# ---------------------------------------------------------------------------
+_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server"
+_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that"
+_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"}
+_META_KEYS = ("name", "displayName", "customName", "description", "actions")
+
+
+class CredentialsRequired(Exception):
+ """A save moved a tool to a new host without supplying new secrets."""
+
+
+def denied_response(err: AccessDenied):
+ """JSON response for an :class:`AccessDenied` (403 or 404)."""
+ return make_response(jsonify({"success": False, "message": err.message}), err.status)
+
+
+def check_action(ra: ResourceAccess, action: str) -> None:
+ """Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``."""
+ if not ra.can(action):
+ raise AccessDenied(403, _FORBIDDEN_MESSAGE)
+
+
+def url_host(url: Any) -> str:
+ """Lower-cased host of ``url`` ('' when it has none)."""
+ try:
+ return (urlparse(str(url or "").strip()).hostname or "").lower()
+ except ValueError:
+ return ""
+
+
+def _has_value(value: Any) -> bool:
+ return value is not None and value != ""
+
+
+def _secret_props(action: Any):
+ """Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params."""
+ if not isinstance(action, dict):
+ return
+ for section in API_TOOL_SECRET_SECTIONS:
+ block = action.get(section)
+ props = block.get("properties") if isinstance(block, dict) else None
+ if not isinstance(props, dict):
+ continue
+ for param, spec in props.items():
+ if isinstance(spec, dict):
+ yield section, param, spec
+
+
+def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict:
+ """Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values."""
+ config = config or {}
+ blob = config.get(API_TOOL_SECRETS_KEY)
+ secrets: dict = decrypt_credentials(blob, owner_id) if blob else {}
+ for name, action in (config.get("actions") or {}).items():
+ for section, param, spec in _secret_props(action):
+ value = spec.get("value")
+ if _has_value(value):
+ secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value)
+ return secrets
+
+
+def mask_api_tool_config(config: dict) -> dict:
+ """Copy of an api_tool config with header/query values blanked and ``has_value`` set.
+
+ Args:
+ config: The stored ``user_tools.config``.
+
+ Returns:
+ A deep copy safe to return to any caller: the encrypted blob is dropped
+ and every header / query-param entry has ``value: ""`` plus ``has_value``.
+ """
+ out = copy.deepcopy(config or {})
+ out.pop(API_TOOL_SECRETS_KEY, None)
+ for action in (out.get("actions") or {}).values():
+ for _section, _param, spec in _secret_props(action):
+ spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value"))
+ spec["value"] = ""
+ return out
+
+
+def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict:
+ """Move api_tool header/query values into an encrypted blob keyed by the owner.
+
+ An incoming entry with a value replaces the stored one; an empty value with
+ ``has_value`` keeps it (legacy plaintext values included); anything else
+ clears it. When an action's URL host changes the stored values are not
+ carried over.
+
+ Args:
+ new_config: The config the client sent.
+ existing_config: The stored config (``{}`` on create).
+ owner_id: The tool row's ``user_id`` — the encryption key owner.
+
+ Returns:
+ The config to persist.
+
+ Raises:
+ CredentialsRequired: a host changed, the client asked to keep a value,
+ and there is nothing to keep.
+ """
+ existing_config = existing_config or {}
+ stored = _stored_api_tool_secrets(existing_config, owner_id)
+ old_actions = existing_config.get("actions") or {}
+ out = copy.deepcopy(new_config or {})
+ out.pop(API_TOOL_SECRETS_KEY, None)
+ sealed: dict = {}
+ for name, action in (out.get("actions") or {}).items():
+ old = old_actions.get(name) if isinstance(old_actions, dict) else None
+ moved = isinstance(old, dict) and url_host(old.get("url")) != url_host(
+ action.get("url") if isinstance(action, dict) else ""
+ )
+ prior = {} if moved else stored.get(name, {})
+ for section, param, spec in _secret_props(action):
+ value = spec.get("value")
+ if _has_value(value):
+ kept = value
+ elif spec.get("has_value"):
+ kept = (prior.get(section) or {}).get(param)
+ if not _has_value(kept):
+ if moved:
+ raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
+ kept = None
+ else:
+ kept = None
+ spec["value"] = ""
+ spec["has_value"] = kept is not None
+ if kept is not None:
+ sealed.setdefault(name, {}).setdefault(section, {})[param] = kept
+ if sealed:
+ out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id)
+ return out
+
+
+def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool:
+ """Whether an api_tool config change touches endpoints or secrets.
+
+ Descriptions, parameter schemas and on/off flags are ``edit``; a new or
+ changed URL, a new action (it brings a URL), a secret value or any other
+ config key is ``edit_credentials``.
+ """
+ new_config = new_config or {}
+ existing_config = existing_config or {}
+ ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials")
+ if {k: v for k, v in new_config.items() if k not in ignore} != {
+ k: v for k, v in existing_config.items() if k not in ignore
+ }:
+ return True
+ old_actions = existing_config.get("actions") or {}
+ for name, action in (new_config.get("actions") or {}).items():
+ old = old_actions.get(name)
+ if not isinstance(old, dict) or not isinstance(action, dict):
+ return True
+ if str(action.get("url") or "") != str(old.get("url") or ""):
+ return True
+ for _section, _param, spec in _secret_props(action):
+ if _has_value(spec.get("value")):
+ return True
+ return False
+
+
+def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
+ old_url = (existing_config or {}).get("server_url")
+ return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
+
+
+def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
+ """Validate-free merge of an incoming config with the stored one, as the owner.
+
+ Handles the three secret stores: ``config_requirements`` secrets
+ (``encrypted_credentials``), api_tool header/query values, and the MCP
+ host-change rule (a new server host drops stored credentials).
+
+ Raises:
+ CredentialsRequired: the MCP host changed and no new secret arrived.
+ """
+ owner_id = tool_doc["user_id"]
+ existing_config = tool_doc.get("config") or {}
+ if tool_doc.get("name") == "api_tool":
+ return _seal_api_tool_secrets(new_config, existing_config, owner_id)
+ moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config)
+ if moved:
+ existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
+ final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)
+ if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get(
+ "encrypted_credentials"
+ ):
+ raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
+ return final
+
+
+def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict:
+ """``tool_id -> team name`` through which a grant reaches ``user_id``."""
+ ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
+ if not ids:
+ return {}
+ rows = conn.execute(
+ text(
+ """
+ SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name
+ FROM team_resource_grants g
+ JOIN team_members m ON m.team_id = g.team_id
+ JOIN teams t ON t.id = g.team_id
+ WHERE m.user_id = :user_id AND g.resource_type = 'tool'
+ AND g.resource_id = ANY(CAST(:ids AS uuid[]))
+ AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
+ ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name
+ """
+ ),
+ {"user_id": user_id, "ids": ids},
+ ).fetchall()
+ return {str(r[0]): r[1] for r in rows}
+
+
+def _owner_labels(conn: Connection, owner_ids) -> dict:
+ """``user_id -> email`` for the owners that have one on record."""
+ ids = sorted({str(o) for o in owner_ids if o})
+ if not ids:
+ return {}
+ rows = conn.execute(
+ text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"),
+ {"ids": ids},
+ ).fetchall()
+ return {r[0]: r[1] for r in rows}
+
+
+def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]:
+ """The tool row behind ``ra``, read as its owner."""
+ return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
+
+
def transform_actions(actions_metadata):
"""Set default flags on action metadata for storage.
@@ -239,6 +488,10 @@ class GetTools(Resource):
team_shared = visible_with_access(conn, user, "tool")
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
shared_rows = tools_repo.list_by_ids(shared_ids)
+ switches = settings_many(conn, "tool", [*owned_ids, *shared_ids])
+ prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids)
+ shared_via = _shared_via(conn, user, shared_ids)
+ owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows])
user_tools = []
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
@@ -257,14 +510,25 @@ class GetTools(Resource):
):
tool_copy["config"]["has_encrypted_credentials"] = True
tool_copy["config"].pop("encrypted_credentials", None)
+ if tool_copy.get("name") == "api_tool":
+ # Header / query-param values are secrets for everyone.
+ tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {})
tool_copy["ownership"] = ownership
return tool_copy
for row in rows:
- user_tools.append(_shape_tool(row))
+ shaped = _shape_tool(row)
+ shaped.update(payload_for("tool", "owner", switches.get(str(row["id"]))))
+ shaped["in_chat"] = bool(row.get("status"))
+ user_tools.append(shaped)
for row in shared_rows:
+ tid = str(row["id"])
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
- shaped["team_access"] = team_shared.get(str(row["id"]))
+ shaped["team_access"] = team_shared.get(tid)
+ shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid)))
+ shaped["in_chat"] = prefs.get(tid, False)
+ shaped["shared_via"] = shared_via.get(tid)
+ shaped["owner_label"] = owner_labels.get(row.get("user_id"))
user_tools.append(shaped)
# ``scheduler`` is dual-registered (default chat tool + agent-
@@ -275,6 +539,7 @@ class GetTools(Resource):
for default_row in default_tools_for_management(user_doc):
default_copy = _row_to_api(default_row)
default_copy["default"] = True
+ default_copy["in_chat"] = bool(default_copy.get("status"))
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
default_copy["builtin"] = True
seen_ids.add(str(default_copy["id"]))
@@ -386,9 +651,12 @@ class CreateTool(Resource):
),
400,
)
- storage_config = _encrypt_secret_fields(
- data["config"], config_requirements, user
- )
+ if data["name"] == "api_tool":
+ storage_config = _seal_api_tool_secrets(data["config"], {}, user)
+ else:
+ storage_config = _encrypt_secret_fields(
+ data["config"], config_requirements, user
+ )
with db_session() as conn:
created = UserToolsRepository(conn).create(
user,
@@ -478,43 +746,47 @@ class UpdateTool(Resource):
),
400,
)
+ if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]:
+ for action_name in list((data["config"]["actions"] or {}).keys()):
+ if not validate_function_name(action_name):
+ return make_response(
+ jsonify(
+ {
+ "success": False,
+ "message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
+ "param": "tools[].function.name",
+ }
+ ),
+ 400,
+ )
try:
update_data: dict = {}
- for key in ("name", "displayName", "customName", "description", "actions"):
+ for key in _META_KEYS:
if key in data:
update_data[key] = data[key]
- if "config" in data:
- if "actions" in data["config"]:
- for action_name in list(data["config"]["actions"].keys()):
- if not validate_function_name(action_name):
- return make_response(
- jsonify(
- {
- "success": False,
- "message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
- "param": "tools[].function.name",
- }
- ),
- 400,
- )
- with db_session() as conn:
- repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
- if not tool_doc:
- return make_response(
- jsonify({"success": False, "message": "Tool not found"}),
- 404,
- )
+ with db_session() as conn:
+ ra = require(conn, "tool", data["id"], user, "use")
+ tool_doc = _load_owned_row(conn, ra)
+ if not tool_doc:
+ return make_response(
+ jsonify({"success": False, "message": "Tool not found"}), 404,
+ )
+ if update_data:
+ check_action(ra, "edit")
+ if "config" in data:
tool_name = tool_doc.get("name", data.get("name"))
+ existing_config = tool_doc.get("config", {}) or {}
+ if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
+ data["config"], existing_config
+ ):
+ check_action(ra, "edit")
+ else:
+ check_action(ra, "edit_credentials")
tool_instance = tool_manager.tools.get(tool_name)
config_requirements = (
- tool_instance.get_config_requirements()
- if tool_instance
- else {}
+ tool_instance.get_config_requirements() if tool_instance else {}
)
- existing_config = tool_doc.get("config", {}) or {}
has_existing_secrets = "encrypted_credentials" in existing_config
-
if config_requirements:
validation_errors = _validate_config(
data["config"], config_requirements,
@@ -529,29 +801,27 @@ class UpdateTool(Resource):
}),
400,
)
-
- update_data["config"] = _merge_secrets_on_update(
- data["config"], existing_config, config_requirements, user
+ update_data["config"] = _prepare_tool_config(
+ tool_doc, data["config"], config_requirements
)
- if "status" in data:
- update_data["status"] = bool(data["status"])
- repo.update(
- str(tool_doc["id"]), user, _api_to_update_fields(update_data),
- )
- else:
if "status" in data:
- update_data["status"] = bool(data["status"])
- with db_session() as conn:
- repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
- if not tool_doc:
- return make_response(
- jsonify({"success": False, "message": "Tool not found"}),
- 404,
+ if ra.access == "owner":
+ update_data["status"] = bool(data["status"])
+ else:
+ # A grantee's chat switch is personal; the owner's
+ # ``status`` is the owner's own chat setting.
+ check_action(ra, "use_in_own")
+ UserToolPreferencesRepository(conn).set_in_chat(
+ user, str(tool_doc["id"]), bool(data["status"])
)
- repo.update(
- str(tool_doc["id"]), user, _api_to_update_fields(update_data),
+ if update_data:
+ UserToolsRepository(conn).update(
+ str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data),
)
+ except AccessDenied as err:
+ return denied_response(err)
+ except CredentialsRequired as err:
+ return make_response(jsonify({"success": False, "message": str(err)}), 400)
except Exception as err:
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -596,7 +866,8 @@ class UpdateToolConfig(Resource):
try:
with db_session() as conn:
repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
+ ra = require(conn, "tool", data["id"], user, "edit_credentials")
+ tool_doc = _load_owned_row(conn, ra)
if not tool_doc:
return make_response(jsonify({"success": False}), 404)
@@ -633,11 +904,13 @@ class UpdateToolConfig(Resource):
400,
)
- final_config = _merge_secrets_on_update(
- data["config"], existing_config, config_requirements, user
- )
+ final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements)
- repo.update(str(tool_doc["id"]), user, {"config": final_config})
+ repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config})
+ except AccessDenied as err:
+ return denied_response(err)
+ except CredentialsRequired as err:
+ return make_response(jsonify({"success": False, "message": str(err)}), 400)
except Exception as err:
current_app.logger.error(
f"Error updating tool config: {err}", exc_info=True
@@ -684,20 +957,12 @@ class UpdateToolActions(Resource):
)
try:
with db_session() as conn:
- repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
- if tool_doc:
- repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
- else:
- # Team editor write path (secrets stay owner-only — actions
- # carry no credentials, so editing them is safe).
- owner = effective_write_owner(conn, "tool", data["id"], user)
- if not owner:
- return make_response(
- jsonify({"success": False, "message": "Tool not found"}),
- 404,
- )
- repo.update(data["id"], owner, {"actions": data["actions"]})
+ # ``edit`` covers action on/off, descriptions and approval
+ # (``require_approval``); actions carry no credentials.
+ ra = require(conn, "tool", data["id"], user, "edit")
+ UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["actions"]})
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating tool actions: {err}", exc_info=True
@@ -753,16 +1018,19 @@ class UpdateToolStatus(Resource):
400,
)
with db_session() as conn:
- repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
- if not tool_doc:
- return make_response(
- jsonify({"success": False, "message": "Tool not found"}),
- 404,
+ ra = require(conn, "tool", data["id"], user, "use")
+ if ra.access == "owner":
+ UserToolsRepository(conn).update(
+ ra.resource_id, ra.owner_id, {"status": bool(data["status"])},
)
- repo.update(
- str(tool_doc["id"]), user, {"status": bool(data["status"])},
- )
+ else:
+ # A grantee's "In my chats" switch is personal.
+ check_action(ra, "use_in_own")
+ UserToolPreferencesRepository(conn).set_in_chat(
+ user, ra.resource_id, bool(data["status"])
+ )
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(
f"Error updating tool status: {err}", exc_info=True
@@ -802,13 +1070,13 @@ class DeleteTool(Resource):
)
try:
with db_session() as conn:
- repo = UserToolsRepository(conn)
- tool_doc = repo.get_any(data["id"], user)
- if not tool_doc:
- return make_response(
- jsonify({"success": False, "message": "Tool not found"}), 404
- )
- repo.delete(str(tool_doc["id"]), user)
+ ra = require(conn, "tool", data["id"], user, "delete")
+ # Grants are removed by the ``user_tools`` delete trigger and
+ # chat preferences by FK cascade; the switches have no FK.
+ UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id)
+ delete_settings(conn, "tool", ra.resource_id)
+ except AccessDenied as err:
+ return denied_response(err)
except Exception as err:
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py
index e36e5e1c..3faad82e 100644
--- a/docsgpt/api/user/workflows/routes.py
+++ b/docsgpt/api/user/workflows/routes.py
@@ -2,13 +2,15 @@
from typing import Any, Dict, List, Optional, Set
-from flask import current_app, request
+from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
+from sqlalchemy import text as sql_text
from docsgpt.agents.workflows.cel_evaluator import (
CelEvaluationError,
validate_cel_expression,
)
+from docsgpt.api.user.resource_access import AccessDenied, resolve
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
@@ -46,6 +48,60 @@ def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str)
return repo.get_by_legacy_id(workflow_id, user_id)
+def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
+ """Resolve a workflow the caller may ``action``, and the id to act as.
+
+ The caller's own workflow is always theirs. Otherwise access comes from
+ an agent of the workflow's owner that uses it: ``view`` to read it,
+ ``edit`` to change it, ``delete`` to remove it (checked on that agent).
+
+ Args:
+ conn: Open database connection.
+ workflow_id: Workflow UUID or legacy id.
+ user_id: The caller.
+ action: Agent action required (``view``, ``edit`` or ``delete``).
+
+ Returns:
+ ``(workflow, acting_user_id)``.
+
+ Raises:
+ AccessDenied: 404 when not visible, 403 when the role can't ``action``.
+ """
+ repo = WorkflowsRepository(conn)
+ own = _resolve_workflow(repo, workflow_id, user_id)
+ if own is not None:
+ return own, user_id
+ if not looks_like_uuid(str(workflow_id)):
+ raise AccessDenied(404, "Workflow not found")
+ workflow = repo.get_by_id(str(workflow_id))
+ if workflow is None:
+ raise AccessDenied(404, "Workflow not found")
+ agent_ids = conn.execute(
+ sql_text(
+ "SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"
+ ),
+ {"wid": str(workflow["id"]), "owner": workflow["user_id"]},
+ ).scalars().all()
+ visible = False
+ for agent_id in agent_ids:
+ ra = resolve(conn, "agent", str(agent_id), user_id)
+ if ra is None:
+ continue
+ visible = True
+ if ra.can(action):
+ return workflow, ra.owner_id
+ if not visible:
+ raise AccessDenied(404, "Workflow not found")
+ raise AccessDenied(403, "Your access to this item doesn't allow that")
+
+
+def _denied(err: AccessDenied):
+ """403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
+ return make_response(
+ jsonify({"success": False, "error": err.message, "message": err.message}), err.status
+ )
+
+
def _write_graph(
conn,
pg_workflow_id: str,
@@ -499,10 +555,10 @@ class WorkflowDetail(Resource):
user_id = get_user_id()
try:
with db_readonly() as conn:
- repo = WorkflowsRepository(conn)
- workflow = _resolve_workflow(repo, workflow_id, user_id)
- if workflow is None:
- return error_response("Workflow not found", 404)
+ try:
+ workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view")
+ except AccessDenied as denied:
+ return _denied(denied)
pg_workflow_id = str(workflow["id"])
graph_version = get_workflow_graph_version(workflow)
nodes = WorkflowNodesRepository(conn).find_by_version(
@@ -533,21 +589,23 @@ class WorkflowDetail(Resource):
nodes_data = data.get("nodes", [])
edges_data = data.get("edges", [])
- validation_errors = validate_workflow_structure(
- nodes_data, edges_data, user_id=user_id
- )
- if validation_errors:
- return error_response(
- "Workflow validation failed", errors=validation_errors
- )
- nodes_data = normalize_agent_node_json_schemas(nodes_data)
-
try:
with db_session() as conn:
repo = WorkflowsRepository(conn)
- workflow = _resolve_workflow(repo, workflow_id, user_id)
- if workflow is None:
- return error_response("Workflow not found", 404)
+ try:
+ workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit")
+ except AccessDenied as denied:
+ return _denied(denied)
+ # Validated as the owner: the workflow runs with the owner's
+ # models, so their BYOM ids are the ones that must resolve.
+ validation_errors = validate_workflow_structure(
+ nodes_data, edges_data, user_id=acting
+ )
+ if validation_errors:
+ return error_response(
+ "Workflow validation failed", errors=validation_errors
+ )
+ nodes_data = normalize_agent_node_json_schemas(nodes_data)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
next_graph_version = current_graph_version + 1
@@ -557,7 +615,7 @@ class WorkflowDetail(Resource):
nodes_data, edges_data,
)
repo.update(
- pg_workflow_id, user_id,
+ pg_workflow_id, acting,
{
"name": name,
"description": description,
@@ -582,11 +640,12 @@ class WorkflowDetail(Resource):
try:
with db_session() as conn:
repo = WorkflowsRepository(conn)
- workflow = _resolve_workflow(repo, workflow_id, user_id)
- if workflow is None:
- return error_response("Workflow not found", 404)
+ try:
+ workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete")
+ except AccessDenied as denied:
+ return _denied(denied)
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
- repo.delete(str(workflow["id"]), user_id)
+ repo.delete(str(workflow["id"]), acting)
except Exception as err:
return _workflow_error_response("Failed to delete workflow", err)
diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py
index ebd25567..1e6c3866 100644
--- a/docsgpt/storage/db/models.py
+++ b/docsgpt/storage/db/models.py
@@ -187,6 +187,23 @@ Index(
team_resource_grants_table.c.resource_id,
)
+# Per-asset sharing switches set by the owner (migration 0038). A missing row
+# means every switch is at its default; keys are validated in
+# ``docsgpt/api/user/resource_access.py``.
+resource_share_settings_table = Table(
+ "resource_share_settings",
+ metadata,
+ Column("resource_type", Text, primary_key=True),
+ Column("resource_id", UUID(as_uuid=True), primary_key=True),
+ Column("settings", JSONB, nullable=False, server_default="{}"),
+ Column("updated_by", Text),
+ Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+ CheckConstraint(
+ "resource_type IN ('agent', 'source', 'prompt', 'tool')",
+ name="resource_share_settings_type_check",
+ ),
+)
+
prompts_table = Table(
"prompts",
@@ -218,6 +235,22 @@ user_tools_table = Table(
Column("legacy_mongo_id", Text),
)
+# A grantee's personal "In my chats" switch for a tool shared with them
+# (migration 0038). The owner's own switch stays ``user_tools.status``.
+user_tool_preferences_table = Table(
+ "user_tool_preferences",
+ metadata,
+ Column("user_id", Text, primary_key=True),
+ Column(
+ "tool_id",
+ UUID(as_uuid=True),
+ ForeignKey("user_tools.id", ondelete="CASCADE"),
+ primary_key=True,
+ ),
+ Column("in_chat", Boolean, nullable=False, server_default="false"),
+ Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
+)
+
token_usage_table = Table(
"token_usage",
metadata,
diff --git a/docsgpt/storage/db/repositories/team_resource_grants.py b/docsgpt/storage/db/repositories/team_resource_grants.py
index 8492591e..7af5402b 100644
--- a/docsgpt/storage/db/repositories/team_resource_grants.py
+++ b/docsgpt/storage/db/repositories/team_resource_grants.py
@@ -107,8 +107,9 @@ class TeamResourceGrantsRepository:
that one member (the caller must have validated they're a team member).
``ON CONFLICT`` on the functional dedup index makes re-sharing
last-write-wins on ``access_level``. The caller MUST have verified
- ``granted_by`` owns the resource (dispatched by ``resource_type``) — the
- polymorphic table has no FK to catch a type/id mismatch.
+ ``granted_by`` holds ``share`` on the resource (``resource_access.require``,
+ dispatched by ``resource_type``) and pass the real owner as ``owner_id`` —
+ the polymorphic table has no FK to catch a type/id mismatch.
"""
result = self._conn.execute(
text(
diff --git a/docsgpt/storage/db/repositories/user_tool_preferences.py b/docsgpt/storage/db/repositories/user_tool_preferences.py
new file mode 100644
index 00000000..29ad5986
--- /dev/null
+++ b/docsgpt/storage/db/repositories/user_tool_preferences.py
@@ -0,0 +1,91 @@
+"""Repository for the ``user_tool_preferences`` table.
+
+A grantee's personal "In my chats" switch for a tool shared with them. The
+owner's own switch stays in ``user_tools.status``; a missing row here means
+off, so sharing a tool never adds it to anyone's chats.
+"""
+
+from __future__ import annotations
+
+from typing import Iterable
+
+from sqlalchemy import Connection, text
+
+from docsgpt.storage.db.base_repository import looks_like_uuid
+
+
+class UserToolPreferencesRepository:
+ """Per-user, per-tool chat preferences for shared tools."""
+
+ def __init__(self, conn: Connection) -> None:
+ self._conn = conn
+
+ def set_in_chat(self, user_id: str, tool_id: str, in_chat: bool) -> None:
+ """Upsert the caller's "In my chats" switch for one tool.
+
+ Args:
+ user_id: The grantee's user id.
+ tool_id: The shared tool's UUID.
+ in_chat: Whether the tool joins the grantee's agentless chats.
+ """
+ self._conn.execute(
+ text(
+ """
+ INSERT INTO user_tool_preferences (user_id, tool_id, in_chat)
+ VALUES (:user_id, CAST(:tool_id AS uuid), :in_chat)
+ ON CONFLICT (user_id, tool_id)
+ DO UPDATE SET in_chat = EXCLUDED.in_chat, updated_at = now()
+ """
+ ),
+ {"user_id": user_id, "tool_id": str(tool_id), "in_chat": bool(in_chat)},
+ )
+
+ def in_chat_many(self, user_id: str, tool_ids: Iterable[str]) -> dict[str, bool]:
+ """``tool_id -> in_chat`` for the given tools; missing rows are False.
+
+ Args:
+ user_id: The grantee's user id.
+ tool_ids: Tool ids to look up (non-UUIDs are ignored).
+
+ Returns:
+ A dict with one entry per UUID-shaped input id.
+ """
+ ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
+ out = {tid: False for tid in ids}
+ if not ids or not user_id:
+ return out
+ rows = self._conn.execute(
+ text(
+ """
+ SELECT tool_id, in_chat FROM user_tool_preferences
+ WHERE user_id = :user_id AND tool_id = ANY(CAST(:ids AS uuid[]))
+ """
+ ),
+ {"user_id": user_id, "ids": ids},
+ ).fetchall()
+ for tool_id, in_chat in rows:
+ out[str(tool_id)] = bool(in_chat)
+ return out
+
+ def list_in_chat_tool_ids(self, user_id: str) -> list[str]:
+ """Ids of tools the user switched into their chats (any owner).
+
+ Args:
+ user_id: The grantee's user id.
+
+ Returns:
+ Tool ids as strings; access must still be re-checked by the caller.
+ """
+ if not user_id:
+ return []
+ rows = self._conn.execute(
+ text(
+ """
+ SELECT tool_id FROM user_tool_preferences
+ WHERE user_id = :user_id AND in_chat = true
+ ORDER BY updated_at
+ """
+ ),
+ {"user_id": user_id},
+ ).fetchall()
+ return [str(r[0]) for r in rows]
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index eaf5651a..4e918b93 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -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 ` ` or ``)
-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
diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx
index 690d2bd0..2344ca16 100644
--- a/frontend/src/Navigation.tsx
+++ b/frontend/src/Navigation.tsx
@@ -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
}
diff --git a/frontend/src/agents/AgentCard.test.tsx b/frontend/src/agents/AgentCard.test.tsx
new file mode 100644
index 00000000..4369bc92
--- /dev/null
+++ b/frontend/src/agents/AgentCard.test.tsx
@@ -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' ? (
+
+ ) : 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(
+
+
+ ,
+ );
+ });
+ };
+
+ const openMenu = async () => {
+ const trigger = container.querySelector(
+ '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('[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('[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('[data-testid="confirm"]')!.click(),
+ );
+ expect(mocks.dispatch).toHaveBeenCalledWith({
+ type: 'actionToast/showActionToast',
+ payload: { variant: 'destructive', message: 'Only the owner can delete' },
+ });
+ });
+});
diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx
index b8f8d71f..f08811c4 100644
--- a/frontend/src/agents/AgentCard.tsx
+++ b/frontend/src/agents/AgentCard.tsx
@@ -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 = {
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({
}
}}
>
-
+ {menuOptions.length > 0 && (
+
+ )}
{/* 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' && (
diff --git a/frontend/src/agents/AgentPageHeader.test.tsx b/frontend/src/agents/AgentPageHeader.test.tsx
index 32fc5b5d..de78df09 100644
--- a/frontend/src/agents/AgentPageHeader.test.tsx
+++ b/frontend/src/agents/AgentPageHeader.test.tsx
@@ -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(
+
+
+ ,
+ );
+ });
+ 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(() => {
diff --git a/frontend/src/agents/AgentPageHeader.tsx b/frontend/src/agents/AgentPageHeader.tsx
index 6bc6c23f..45c53450 100644
--- a/frontend/src/agents/AgentPageHeader.tsx
+++ b/frontend/src/agents/AgentPageHeader.tsx
@@ -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) {
diff --git a/frontend/src/agents/AgentRouteGuard.test.tsx b/frontend/src/agents/AgentRouteGuard.test.tsx
new file mode 100644
index 00000000..85453994
--- /dev/null
+++ b/frontend/src/agents/AgentRouteGuard.test.tsx
@@ -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(
+
+
+
+
+
+ }
+ />
+ } />
+
+ ,
+ );
+ });
+ };
+
+ 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);
+ });
+});
diff --git a/frontend/src/agents/AgentRouteGuard.tsx b/frontend/src/agents/AgentRouteGuard.tsx
new file mode 100644
index 00000000..ed487d1f
--- /dev/null
+++ b/frontend/src/agents/AgentRouteGuard.tsx
@@ -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 ;
+ return <>{children}>;
+}
diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx
index 08b06060..0132d64b 100644
--- a/frontend/src/agents/NewAgent.test.tsx
+++ b/frontend/src/agents/NewAgent.test.tsx
@@ -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' ? (
+
+ confirm
+
+ ) : null,
+}));
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
vi.mock('../navigation/SectionPills', () => ({
default: () =>
,
@@ -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(
+
+
+ }
+ />
+
+ ,
+ );
+ });
+ };
+
+ const buttonByText = (text: string) =>
+ Array.from(container.querySelectorAll('button')).find((b) =>
+ b.textContent?.includes(text),
+ );
+
+ const menuLabels = async () => {
+ const menu = container.querySelector(
+ 'button[aria-label="agents.form.buttons.moreActions"]',
+ )!;
+ await act(async () => {
+ menu.dispatchEvent(
+ new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
+ );
+ });
+ return Array.from(
+ document.querySelectorAll('[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('[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(
+ 'input[placeholder="agents.form.placeholders.enterTokenLimit"]',
+ )!;
+ const tokenSwitch = token
+ .closest('[data-slot="setting-row"]')
+ ?.querySelector('[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('[data-testid="confirm-delete"]')!
+ .click(),
+ );
+ expect(mocks.dispatch).toHaveBeenCalledWith({
+ type: 'actionToast/showActionToast',
+ payload: { variant: 'destructive', message: 'Only the owner can delete' },
+ });
+ });
+});
diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx
index e240f007..9176bd44 100644
--- a/frontend/src/agents/NewAgent.tsx
+++ b/frontend/src/agents/NewAgent.tsx
@@ -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')}
)}
- {modeConfig[effectiveMode].showSaveDraft && (
+ {modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && (
)}
-
- {modeConfig[effectiveMode].buttonText}
-
+ {canSubmit && (
+
+ {modeConfig[effectiveMode].buttonText}
+
+ )}
);
@@ -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' }) {
{
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' }) {
{
setAgent({
...agent,
@@ -1459,15 +1489,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({
@@ -1476,29 +1502,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
})
}
/>
- {modeConfig[effectiveMode].showDelete && agent.id && (
-
-
- setDeleteConfirmation('ACTIVE')}
- className="shrink-0"
+ padding="lg"
+ className="flex-row flex-wrap items-start justify-between"
>
- {t('agents.form.dangerZone.deleteButton')}
-
-
- )}
+
+ setDeleteConfirmation('ACTIVE')}
+ className="shrink-0"
+ >
+ {t('agents.form.dangerZone.deleteButton')}
+
+
+ )}
- {t('agents.form.buttons.publish')}
-
+ can(agent, 'publish') ? (
+
+ {t('agents.form.buttons.publish')}
+
+ ) : undefined
}
/>
diff --git a/frontend/src/agents/agentAccess.test.ts b/frontend/src/agents/agentAccess.test.ts
new file mode 100644
index 00000000..8eb309f6
--- /dev/null
+++ b/frontend/src/agents/agentAccess.test.ts
@@ -0,0 +1,54 @@
+import { canAgent, canOpenAgentEditor } from './agentAccess';
+import type { Agent } from './types';
+
+const agent = (fields: Partial) => ({ 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);
+ });
+});
diff --git a/frontend/src/agents/agentAccess.ts b/frontend/src/agents/agentAccess.ts
new file mode 100644
index 00000000..83ef2505
--- /dev/null
+++ b/frontend/src/agents/agentAccess.ts
@@ -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);
+}
diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx
index 380fd188..2ead2092 100644
--- a/frontend/src/agents/index.tsx
+++ b/frontend/src/agents/index.tsx
@@ -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() {
} />
} />
} />
- } />
- } />
- } />
+ {/* An agent's pages open only for a role that may use them; the
+ guard sends anyone else back to the list. */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
} />
}
+ element={
+
+
+
+ }
/>
{/* Using an agent someone shared. */}
diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts
index 44ce6886..8fa98a32 100644
--- a/frontend/src/agents/types/index.ts
+++ b/frontend/src/agents/types/index.ts
@@ -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
diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx
index bfcb2e9a..0d765d88 100644
--- a/frontend/src/agents/workflow/WorkflowBuilder.tsx
+++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx
@@ -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('INACTIVE');
const [agentDetails, setAgentDetails] = useState('INACTIVE');
+ const [shareModalOpen, setShareModalOpen] = useState(false);
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const [currentAgent, setCurrentAgent] = useState(
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() {
{t('agents.form.sections.preview')}
-
- {primaryActionLabel}
-
+ {canSubmit && (
+
+ {primaryActionLabel}
+
+ )}
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 && (
+ setShareModalOpen(false)}
+ />
+ )}
{canManageAgent && (
`/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',
diff --git a/frontend/src/api/services/teamsService.test.ts b/frontend/src/api/services/teamsService.test.ts
new file mode 100644
index 00000000..0fc3fd60
--- /dev/null
+++ b/frontend/src/api/services/teamsService.test.ts
@@ -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 },
+ });
+ });
+});
diff --git a/frontend/src/api/services/teamsService.ts b/frontend/src/api/services/teamsService.ts
index 2d460feb..818096de 100644
--- a/frontend/src/api/services/teamsService.ts
+++ b/frontend/src/api/services/teamsService.ts
@@ -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//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 =>
+ json(
+ await apiClient.get(
+ `${endpoints.USER.RESOURCE_SETTINGS}?resource_type=${resourceType}&resource_id=${encodeURIComponent(
+ resourceId,
+ )}`,
+ token,
+ ),
+ ) as Promise,
+ updateResourceSettings: async (
+ resourceType: ResourceType,
+ resourceId: string,
+ settings: Record,
+ token: string | null,
+ ): Promise =>
+ json(
+ await apiClient.put(
+ endpoints.USER.RESOURCE_SETTINGS,
+ {
+ resource_type: resourceType,
+ resource_id: resourceId,
+ settings,
+ },
+ token,
+ ),
+ ) as Promise,
+
listAll: async (token: string | null): Promise =>
json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)),
};
diff --git a/frontend/src/components/Chunks.test.tsx b/frontend/src/components/Chunks.test.tsx
index 93460cda..8ec5c9f3 100644
--- a/frontend/src/components/Chunks.test.tsx
+++ b/frontend/src/components/Chunks.test.tsx
@@ -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('[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',
+ ]);
+ });
});
diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx
index 0ab28f53..cbfea62c 100644
--- a/frontend/src/components/Chunks.tsx
+++ b/frontend/src/components/Chunks.tsx
@@ -89,6 +89,11 @@ interface ChunksProps {
/** Where the open chunk is; see {@link OpenChunkPosition}. */
onOpenChunkChange?: (position: OpenChunkPosition) => void;
controllerRef?: React.MutableRefObject;
+ /**
+ * 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 = ({
embedded = false,
onOpenChunkChange,
controllerRef,
+ canEdit = true,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -556,15 +562,17 @@ const Chunks: React.FC = ({
})}
) : null}
- openSheet('add')}
- >
- {t('settings.sources.addChunk')}
-
+ {canEdit ? (
+ openSheet('add')}
+ >
+ {t('settings.sources.addChunk')}
+
+ ) : null}
);
@@ -675,16 +683,18 @@ const Chunks: React.FC = ({
disabled={!canGoNext}
onClick={() => goToChunk(openPosition + 1)}
/>
- openSheet('edit')}
- >
-
- {t('modals.chunk.edit')}
-
+ {canEdit ? (
+ openSheet('edit')}
+ >
+
+ {t('modals.chunk.edit')}
+
+ ) : null}
= ({
);
},
},
- {
- 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),
+ },
+ ]
+ : []),
]}
/>
>
diff --git a/frontend/src/components/ConnectorTree.test.tsx b/frontend/src/components/ConnectorTree.test.tsx
index cde38ca5..f5a6ebb3 100644
--- a/frontend/src/components/ConnectorTree.test.tsx
+++ b/frontend/src/components/ConnectorTree.test.tsx
@@ -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,
+ },
+}));
+
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(
+ '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('[role="menuitem"]'),
+ ).map((el) => el.textContent);
+ };
+
+ it('read-only ConnectorTree hides Sync, keeps headerAction', async () => {
+ await render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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' },
+ };
+ }
+ });
});
diff --git a/frontend/src/components/ConnectorTree.tsx b/frontend/src/components/ConnectorTree.tsx
index 3b5bac0a..ffbe2bb2 100644
--- a/frontend/src/components/ConnectorTree.tsx
+++ b/frontend/src/components/ConnectorTree.tsx
@@ -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 = ({
actionsTarget,
initialPath,
onCrumbsChange,
+ canEdit = true,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -145,28 +151,30 @@ const ConnectorTree: React.FC = ({
const topRightAction = (
<>
{embedded ? null : headerAction}
- setSyncConfirmationModal('ACTIVE')}
- disabled={isSyncing}
- >
- {syncDone ? (
-
- ) : 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).
-
- ) : (
-
- )}
- {isSyncing
- ? `${syncProgress}%`
- : syncDone
- ? t('settings.sources.syncDone')
- : t('settings.sources.sync')}
-
+ {canEdit ? (
+ setSyncConfirmationModal('ACTIVE')}
+ disabled={isSyncing}
+ >
+ {syncDone ? (
+
+ ) : 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).
+
+ ) : (
+
+ )}
+ {isSyncing
+ ? `${syncProgress}%`
+ : syncDone
+ ? t('settings.sources.syncDone')
+ : t('settings.sources.sync')}
+
+ ) : null}
>
);
@@ -188,6 +196,7 @@ const ConnectorTree: React.FC = ({
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
+ canEdit={canEdit}
actionsTarget={actionsTarget}
initialPath={initialPath}
badge={
diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx
index f4f78e1e..1218464c 100644
--- a/frontend/src/components/FileTree.tsx
+++ b/frontend/src/components/FileTree.tsx
@@ -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 = ({
@@ -49,6 +54,7 @@ const FileTree: React.FC = ({
actionsTarget,
initialPath,
onCrumbsChange,
+ canEdit = true,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -226,6 +232,8 @@ const FileTree: React.FC = ({
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 = ({
const topRightAction = (
<>
{embedded ? null : headerAction}
- {!isProcessing ? (
+ {canEdit && !isProcessing ? (
{t('settings.sources.addFile')}
@@ -281,6 +289,7 @@ const FileTree: React.FC = ({
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
+ canEdit={canEdit}
actionsTarget={actionsTarget}
initialPath={initialPath}
columnOrder="size-first"
diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx
index 3147edf3..955f59ad 100644
--- a/frontend/src/components/GraphView.tsx
+++ b/frontend/src/components/GraphView.tsx
@@ -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 & { x?: number; y?: number };
@@ -110,6 +112,7 @@ const GraphView: React.FC = ({
onSelect,
active = true,
onOpenInFiles,
+ canEdit = true,
}) => {
const { t } = useTranslation();
const { isDesktop } = useMediaQuery();
@@ -590,6 +593,7 @@ const GraphView: React.FC = ({
overview={data}
onOpenInFiles={onOpenInFiles}
onChunkSaved={nodeDetail.reload}
+ canEdit={canEdit}
/>
) : null;
diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx
index 0ea7c995..d1f295a1 100644
--- a/frontend/src/components/MessageInput.tsx
+++ b/frontend/src/components/MessageInput.tsx
@@ -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: ,
+ 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();
});
};
diff --git a/frontend/src/components/graph/GraphChunkSheet.tsx b/frontend/src/components/graph/GraphChunkSheet.tsx
index d3327277..c616767f 100644
--- a/frontend/src/components/graph/GraphChunkSheet.tsx
+++ b/frontend/src/components/graph/GraphChunkSheet.tsx
@@ -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}
/>
-
-
- {onOpenInFiles && path ? (
-
{
- close();
- onOpenInFiles(path);
- }}
- >
- {t('settings.sources.graphrag.view.openInFiles')}
-
- ) : null}
-
-
- {t('modals.chunk.edit')}
-
-
+ {/* A reader with nothing to open or edit gets no action row. */}
+ {showOpenInFiles || canEdit ? (
+ <>
+
+
+ {showOpenInFiles ? (
+
{
+ close();
+ onOpenInFiles?.(path);
+ }}
+ >
+ {t('settings.sources.graphrag.view.openInFiles')}
+
+ ) : null}
+ {canEdit ? (
+
+
+ {t('modals.chunk.edit')}
+
+ ) : null}
+
+ >
+ ) : null}
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={
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}
/>
) : (
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}
/>
>
);
diff --git a/frontend/src/components/graph/GraphSourceView.test.tsx b/frontend/src/components/graph/GraphSourceView.test.tsx
index dceedf42..04e1e49f 100644
--- a/frontend/src/components/graph/GraphSourceView.test.tsx
+++ b/frontend/src/components/graph/GraphSourceView.test.tsx
@@ -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 (
{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 (
-
+
{props.embedded ? 'embedded' : 'page'}
setOpen(2)}>
OPEN CHUNK
@@ -104,7 +111,9 @@ vi.mock('../Chunks', async () => {
};
});
vi.mock('../ConnectorTree', () => ({
- default: () =>
,
+ default: (props: { canEdit?: boolean }) => (
+
+ ),
}));
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={Test retrieval }
/>,
@@ -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({
diff --git a/frontend/src/components/graph/GraphSourceView.tsx b/frontend/src/components/graph/GraphSourceView.tsx
index 62faa677..584de5c5 100644
--- a/frontend/src/components/graph/GraphSourceView.tsx
+++ b/frontend/src/components/graph/GraphSourceView.tsx
@@ -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 ? (
@@ -286,6 +296,7 @@ export default function GraphSourceView({
onShowInGraph={showInGraph}
overview={data}
onOpenInFiles={openInFiles}
+ canEdit={canEdit}
/>
diff --git a/frontend/src/components/tree/TreeBrowser.test.tsx b/frontend/src/components/tree/TreeBrowser.test.tsx
index 9d36bd54..3707ef30 100644
--- a/frontend/src/components/tree/TreeBrowser.test.tsx
+++ b/frontend/src/components/tree/TreeBrowser.test.tsx
@@ -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 () => {
diff --git a/frontend/src/components/tree/TreeBrowser.tsx b/frontend/src/components/tree/TreeBrowser.tsx
index d76d3b11..4ca78968 100644
--- a/frontend/src/components/tree/TreeBrowser.tsx
+++ b/frontend/src/components/tree/TreeBrowser.tsx
@@ -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 = ({
actionsTarget,
initialPath,
onCrumbsChange,
+ canEdit = true,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useLoaderState(true, 500);
@@ -688,6 +694,7 @@ const TreeBrowser: React.FC = ({
fileName={file.name}
controllerRef={chunksControllerRef}
onOpenChunkChange={setOpenChunkPosition}
+ canEdit={canEdit}
/>
);
diff --git a/frontend/src/components/ui/list-row.test.tsx b/frontend/src/components/ui/list-row.test.tsx
index 15094134..327f618c 100644
--- a/frontend/src/components/ui/list-row.test.tsx
+++ b/frontend/src/components/ui/list-row.test.tsx
@@ -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(
+
+
+ ,
+ );
+ expect(html).toContain('bg-secondary hover:bg-secondary');
+ expect(html).not.toContain('hover:bg-accent');
+ expect(html).toContain('aria-current="true"');
+ });
});
diff --git a/frontend/src/components/ui/list-row.tsx b/frontend/src/components/ui/list-row.tsx
index 5487082e..cf6862b1 100644
--- a/frontend/src/components/ui/list-row.tsx
+++ b/frontend/src/components/ui/list-row.tsx
@@ -24,6 +24,12 @@ type ListRowProps = Omit, '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 (
-
+
{React.isValidElement(children)
? React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
@@ -94,7 +106,12 @@ function ListRow({
}
return (
-
+
{content}
);
diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx
index 0679c160..a0d1e605 100644
--- a/frontend/src/conversation/Conversation.tsx
+++ b/frontend/src/conversation/Conversation.tsx
@@ -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() {
navigate(agentEditPathFor(selectedAgent))
: undefined
}
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index d1c06fc5..8a6b3c81 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -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": {
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 85db5a4c..520b6108 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -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": {
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index fb2eeb4f..43b88552 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -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": {
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index 7b4ce48a..03f5f466 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -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": {
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index d7e48ac6..71a053d3 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -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": {
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 3d3f5e40..3f10dc96 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -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": {
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 34bb52b6..1220c12c 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -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": {
diff --git a/frontend/src/modals/AgentDetailsModal.test.tsx b/frontend/src/modals/AgentDetailsModal.test.tsx
new file mode 100644
index 00000000..c070e0a6
--- /dev/null
+++ b/frontend/src/modals/AgentDetailsModal.test.tsx
@@ -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' ? (
+
+ ) : 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) => {
+ await act(async () => {
+ root.render(
+ 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('[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');
+ });
+});
diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx
index e52e2123..5f5322e3 100644
--- a/frontend/src/modals/AgentDetailsModal.tsx
+++ b/frontend/src/modals/AgentDetailsModal.tsx
@@ -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 => {
+ 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(null);
const [resetKeyConfirmState, setResetKeyConfirmState] =
useState('INACTIVE');
+ // A failed generate or reset, shown in the modal until the next attempt.
+ const [error, setError] = useState(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,
+ onSuccess: (data: Record) => 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"
>
+ {error && (
+
+
+ {error}
+
+ )}
@@ -216,8 +249,21 @@ export default function AgentDetailsModal({
)}
+ ) : agent.status === 'draft' ? (
+ // A draft has no key yet: the first one is minted on publish.
+
+ {t('modals.agentDetails.apiKeyAfterPublish')}
+
) : (
-
+ // No key shown on a published agent: minting one replaces
+ // any key it has, so it goes through the reset confirmation.
+ setResetKeyConfirmState('ACTIVE')}
+ loading={loadingStates.apiKey}
+ >
{t('modals.agentDetails.generate')}
)}
diff --git a/frontend/src/modals/MCPServerModal.test.tsx b/frontend/src/modals/MCPServerModal.test.tsx
new file mode 100644
index 00000000..8f49dcf6
--- /dev/null
+++ b/frontend/src/modals/MCPServerModal.test.tsx
@@ -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) => {
+ 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 = {}) => {
+ await act(async () => {
+ root.render(
+ {}}
+ server={{ ...server, ...overrides }}
+ onServerSaved={() => {}}
+ />,
+ );
+ });
+ };
+
+ const text = () => document.body.textContent ?? '';
+ const button = (label: string) =>
+ Array.from(
+ document.body.querySelectorAll('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(
+ '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('[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');
+ });
+});
diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx
index cf9cd5b9..5a377591 100644
--- a/frontend/src/modals/MCPServerModal.tsx
+++ b/frontend/src/modals/MCPServerModal.tsx
@@ -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 = {
+ 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
+ }
>
);
@@ -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={
- {t('settings.tools.mcp.testConnection')}
-
+ oauthOwnerOnly ? undefined : (
+
+ {t('settings.tools.mcp.testConnection')}
+
+ )
}
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}
/>
}
>
- {server?.has_encrypted_credentials &&
- formData.auth_type !== 'oauth' && (
-
-
-
- {t('settings.tools.mcp.reenterCredentials', {
- defaultValue:
- 'Re-enter your credentials to test and update the connection.',
- })}
-
-
- )}
+ {isShared && (
+
+
+
+ {t('settings.tools.mcp.sharedCredentialsNotice')}
+ {oauthOwnerOnly &&
+ ` ${t('settings.tools.mcp.sharedOAuthOwnerOnly')}`}
+
+
+ )}
handleInputChange('server_url', e.target.value)}
placeholder="https://example.com/mcp"
+ disabled={oauthOwnerOnly}
/>
+ {serverChanged && (
+
+
+
+ {t('settings.tools.mcp.serverChangedNotice', {
+ interpolation: { escapeValue: false },
+ credential: t(
+ `settings.tools.mcp.credentialNames.${savedSecret.nameKey}`,
+ ),
+ })}
+
+
+ )}
handleInputChange('auth_type', v)}
+ disabled={oauthOwnerOnly}
>
diff --git a/frontend/src/models/misc.ts b/frontend/src/models/misc.ts
index 5c118244..d043280d 100644
--- a/frontend/src/models/misc.ts
+++ b/frontend/src/models/misc.ts
@@ -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;
diff --git a/frontend/src/navigation/sections.test.ts b/frontend/src/navigation/sections.test.ts
index 31c6ff1d..b44c0dfa 100644
--- a/frontend/src/navigation/sections.test.ts
+++ b/frontend/src/navigation/sections.test.ts
@@ -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);
diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts
index 5eb73b39..c74eff01 100644
--- a/frontend/src/navigation/sections.ts
+++ b/frontend/src/navigation/sections.ts
@@ -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 = {
+ 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 }],
};
}
diff --git a/frontend/src/navigation/useSectionContext.ts b/frontend/src/navigation/useSectionContext.ts
index c8296398..75e86266 100644
--- a/frontend/src/navigation/useSectionContext.ts
+++ b/frontend/src/navigation/useSectionContext.ts
@@ -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 {
diff --git a/frontend/src/navigation/useSectionResolver.ts b/frontend/src/navigation/useSectionResolver.ts
index 5e509639..448e4cbf 100644
--- a/frontend/src/navigation/useSectionResolver.ts
+++ b/frontend/src/navigation/useSectionResolver.ts
@@ -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],
);
diff --git a/frontend/src/preferences/PromptsModal.test.tsx b/frontend/src/preferences/PromptsModal.test.tsx
index e32045b3..f12b3d3f 100644
--- a/frontend/src/preferences/PromptsModal.test.tsx
+++ b/frontend/src/preferences/PromptsModal.test.tsx
@@ -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(
+ 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('textarea')?.readOnly,
+ ).toBe(true);
+ expect(
+ document.body.querySelector('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');
+ });
});
diff --git a/frontend/src/preferences/PromptsModal.tsx b/frontend/src/preferences/PromptsModal.tsx
index fc933a6d..2f734a60 100644
--- a/frontend/src/preferences/PromptsModal.tsx
+++ b/frontend/src/preferences/PromptsModal.tsx
@@ -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 (
@@ -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}
/>
);
}
diff --git a/frontend/src/settings/Prompts.test.tsx b/frontend/src/settings/Prompts.test.tsx
index e45b5aef..6815ca8b 100644
--- a/frontend/src/settings/Prompts.test.tsx
+++ b/frontend/src/settings/Prompts.test.tsx
@@ -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 }) => (
+
+ confirm
+
+ ),
+}));
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
(
+ 'button[role="combobox"]',
+ )!;
+ trigger.dispatchEvent(
+ new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
+ );
+ trigger.click();
+ });
+ const row = (name: string) =>
+ Array.from(
+ document.body.querySelectorAll(
+ '[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',
+ },
+ }),
+ );
+ });
+ });
});
diff --git a/frontend/src/settings/Prompts.tsx b/frontend/src/settings/Prompts.tsx
index 5601111b..22029c81 100644
--- a/frontend/src/settings/Prompts.tsx
+++ b/frontend/src/settings/Prompts.tsx
@@ -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({
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(
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({
{t('settings.sources.noResults')}
{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 (
- {canModify ? (
+ {canEdit ? (
) : (
)}
- {
- e.stopPropagation();
- handleDuplicatePrompt(prompt);
- }}
- label={t('settings.general.promptActions.duplicate')}
- >
-
-
- {canShare && (
+ {can(prompt, 'duplicate') && (
+ {
+ e.stopPropagation();
+ handleDuplicatePrompt(prompt);
+ }}
+ label={t('settings.general.promptActions.duplicate')}
+ >
+
+
+ )}
+ {canSharePrompt(prompt) && (
)}
- {canModify && (
+ {canDeletePrompt(prompt) && (
);
- 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) && (
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 && (
diff --git a/frontend/src/settings/SourceConfigModal.test.tsx b/frontend/src/settings/SourceConfigModal.test.tsx
new file mode 100644
index 00000000..02d73e5d
--- /dev/null
+++ b/frontend/src/settings/SourceConfigModal.test.tsx
@@ -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 => ({
+ 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(
+ ,
+ );
+ });
+ };
+
+ 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);
+ });
+});
diff --git a/frontend/src/settings/SourceConfigModal.tsx b/frontend/src/settings/SourceConfigModal.tsx
index 3942e113..4266a35a 100644
--- a/frontend/src/settings/SourceConfigModal.tsx
+++ b/frontend/src/settings/SourceConfigModal.tsx
@@ -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(() =>
configToOptions(document?.config),
diff --git a/frontend/src/settings/Sources.test.tsx b/frontend/src/settings/Sources.test.tsx
new file mode 100644
index 00000000..4406dbcf
--- /dev/null
+++ b/frontend/src/settings/Sources.test.tsx
@@ -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 }) => (
+
+ ),
+ 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 => ({
+ 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(
+ ,
+ );
+ });
+ };
+
+ const menuItems = async () => {
+ const trigger = container.querySelector(
+ '[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('[role="menuitem"]'),
+ ).map((el) => el.textContent);
+ };
+
+ const clickItem = async (label: string) => {
+ const item = Array.from(
+ document.querySelectorAll('[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('[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('[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');
+ });
+});
diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx
index 69f349cf..22f748a7 100644
--- a/frontend/src/settings/Sources.tsx
+++ b/frontend/src/settings/Sources.tsx
@@ -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({
) : null;
+ // Chunk, file, wiki and graph writes follow the source's `edit` action.
+ const viewCanEdit = documentToView ? can(documentToView, 'edit') : false;
+
return documentToView ? (
{documentToView.config?.kind === 'wiki' ||
@@ -528,10 +557,7 @@ export default function Sources({
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' ? (
setDocumentToView(undefined)}
headerAction={testRetrievalAction}
@@ -555,6 +583,7 @@ export default function Sources({
) : (
setDocumentToView(undefined)}
headerAction={testRetrievalAction}
@@ -564,6 +593,7 @@ export default function Sources({
setDocumentToView(undefined)}
headerAction={testRetrievalAction}
/>
@@ -663,10 +693,10 @@ export default function Sources({
- {document.ownership === 'team' && (
+ {roleOf(document) !== 'owner' && (
- {document.team_access === 'editor'
+ {roleOf(document) === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
diff --git a/frontend/src/settings/Teams.test.tsx b/frontend/src/settings/Teams.test.tsx
new file mode 100644
index 00000000..e0c1ebe1
--- /dev/null
+++ b/frontend/src/settings/Teams.test.tsx
@@ -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
>,
+ 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) => {
+ 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: () =>
,
+}));
+// Render the ⋯ menu's options inline so tests can see them.
+vi.mock('../components/ui/dropdown-menu', () => ({
+ ActionMenu: ({ options }: { options: Array<{ label: string }> }) => (
+
+ {options.map((o) => (
+ {o.label}
+ ))}
+
+ ),
+}));
+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 = {}) => ({
+ 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(
+ '[data-testid="shared-resource-row"]',
+ ),
+ );
+
+describe('Teams page', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ const setTeam = (over: Record = {}) => {
+ 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(
+
+
+ ,
+ );
+ });
+ 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(
+ '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');
+ });
+});
diff --git a/frontend/src/settings/Teams.tsx b/frontend/src/settings/Teams.tsx
index de14fb5c..37a45ff6 100644
--- a/frontend/src/settings/Teams.tsx
+++ b/frontend/src/settings/Teams.tsx
@@ -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) =>
+ `${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();
+ 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 = [
'agent',
'source',
@@ -91,6 +162,14 @@ const RESOURCE_TYPES: ReadonlyArray = [
'tool',
];
+// Filter pill order on the shared resources list.
+const FILTER_TYPES: ReadonlyArray = [
+ '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('INACTIVE');
const [memberToRemove, setMemberToRemove] = useState(null);
+ // The caller's role in the selected team, as the grants endpoint reports it.
+ const [teamRole, setTeamRole] = useState(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('all');
+ const [resourceQuery, setResourceQuery] = useState('');
+ const [openResourceKey, setOpenResourceKey] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [drawerSettings, setDrawerSettings] =
+ useState(null);
+ const [busyGrants, setBusyGrants] = useState>(new Set());
+ const [shareTarget, setShareTarget] = useState(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 = {
+ 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) => (
@@ -631,20 +912,28 @@ export default function Teams() {
)}
- {isAdmin && (
+ {(isAdmin || isTeamOwner) && (
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() {
- {grants.length === 0 ? (
+ {sharedResources.length === 0 ? (
) : (
-
- {grants.map((g) => (
-
- {resourceTypeIcon(g.resource_type)}
-
- }
- title={
-
- {resolveResourceName(g)}
-
- }
- trailing={
- <>
-
- {accessLevelLabel(g.access_level)}
-
- {isAdmin && (
- handleUnshare(g)}
- />
- )}
- >
- }
+ <>
+
+
+
+ value && setResourceFilter(value as ResourceFilter)
+ }
+ aria-label={t('settings.teams.sharedList.filterLabel')}
+ >
+ {(['all', ...FILTER_TYPES] as ResourceFilter[]).map(
+ (value) => (
+
+ {t(`settings.teams.sharedList.filter.${value}`)}{' '}
+ {resourceCounts[value]}
+
+ ),
+ )}
+
+
+
setResourceQuery(e.target.value)}
/>
- ))}
-
+
+ {visibleResources.length === 0 ? (
+
+ ) : (
+
+ {visibleResources.map((r) => {
+ const isOpen = drawerOpen && openResourceKey === r.key;
+ return (
+
+ {resourceTypeIcon(r.type)}
+
+ }
+ title={
+
+ {resourceName(r)}
+
+ }
+ description={t('settings.teams.sharedList.meta', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(r.type),
+ owner: ownerLabel(r),
+ })}
+ trailing={
+ <>
+
+ {resourceBadge(r)}
+
+
+ >
+ }
+ >
+ openDrawerFor(r)}
+ />
+
+ );
+ })}
+
+ )}
+ >
)}
)}
+
!open && closeDrawer()}
+ >
+ {openResource && selected && (
+
+
+ {/* pr-12 keeps the header clear of the close X. */}
+
+
+ {resourceTypeIcon(openResource.type)}
+
+
+
+ {resourceName(openResource)}
+
+
+ {t('settings.teams.drawer.subtitle', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ owner: ownerLabel(openResource),
+ })}
+
+
+
+
+
{
+ const path = openAssetPath(openResource);
+ closeDrawer();
+ navigate(path);
+ }}
+ >
+
+ {t('settings.teams.drawer.open', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ })}
+
+ {callerCanShare && (
+
{
+ setDrawerOpen(false);
+ setShareTarget(openResource);
+ }}
+ >
+
+ {t('settings.teams.drawer.manageSharing')}
+
+ )}
+
+
+
+
+
+ {ownerLabel(openResource)}
+
+
+ {grantedAt(openResource)}
+
+
+ {callerAccessLabel(openCaller?.access)}
+
+
+
+
+
+
+
+ {[
+ ...(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 (
+
+
+ {initialOf(isTeam ? selected.name : label)}
+
+
+ }
+ title={{label} }
+ description={
+ isTeam
+ ? t('settings.teams.drawer.teamGrant')
+ : t('settings.teams.drawer.memberGrant')
+ }
+ trailing={
+ <>
+ {callerCanShare ? (
+
+ handleGrantAccess(g, value as AccessLevel)
+ }
+ >
+
+
+
+
+ {(['viewer', 'editor'] as const).map(
+ (level) => (
+
+ {accessLevelLabel(level)}
+
+ ),
+ )}
+
+
+ ) : (
+
+ {accessLevelLabel(g.access_level)}
+
+ )}
+ {(callerCanShare || isAdmin) && (
+ handleUnshare(g)}
+ />
+ )}
+ >
+ }
+ />
+ );
+ })}
+
+
+
+ {t('settings.teams.drawer.otherTeamsHint')}
+
+
+
+
+
+
+ {capabilityLines(
+ t,
+ openResource.type,
+ resolveSettings(
+ openResource.type,
+ drawerSettings?.settings,
+ ),
+ ).map((line) => (
+
+ {line.allowed ? (
+
+ ) : (
+
+ )}
+
+ {line.text}
+
+
+ ))}
+
+
+ {t('settings.teams.drawer.capabilitiesHint')}
+
+
+
+
+
+ )}
+
+
+ {shareTarget && (
+
{
+ setShareTarget(null);
+ // Back to the drawer, with the grants the dialog may have changed.
+ setDrawerOpen(true);
+ refreshGrants();
+ }}
+ />
+ )}
+
@@ -923,6 +1508,7 @@ export default function Teams() {
{
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(
+ '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('input'),
+ ).find((i) => i.placeholder === '••••••••');
+ expect(secret && disabled(secret)).toBe(true);
+ container
+ .querySelectorAll('[role="switch"]')
+ .forEach((sw) => expect(disabled(sw)).toBe(true));
+ await expandFirstAction();
+ container
+ .querySelectorAll('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(
+ `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('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('input'),
+ ).find((i) => i.value === 'https://example.com');
+ expect(url?.disabled).toBe(true);
+ const headerValue = container.querySelector(
+ '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(
+ '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(
+ {}}
+ handleGoBack={goBack}
+ />,
+ );
+ });
+ const name = container.querySelector(
+ '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('[role="alert"]')?.textContent,
+ ).toBe('settings.tools.saveFailed');
+ expect(goBack).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx
index 65e7f9d9..b8d8d628 100644
--- a/frontend/src/settings/ToolConfig.tsx
+++ b/frontend/src/settings/ToolConfig.tsx
@@ -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 = {
'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
>(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}
/>
-
- {t('settings.tools.save')}
-
+ {!readOnly && (
+
+ {t('settings.tools.save')}
+
+ )}
{saveError && (
@@ -383,6 +410,7 @@ export default function ToolConfig({
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={t('settings.tools.customNamePlaceholder')}
+ disabled={!canEdit}
/>
@@ -394,7 +422,10 @@ export default function ToolConfig({
size="xs"
title={t('settings.tools.authentication')}
/>
-
+
-
+
)}
@@ -415,7 +446,7 @@ export default function ToolConfig({
{tool.config.actions &&
Object.keys(tool.config.actions).length > 0 ? (
-
+
+
+
) : (
{
const isExpanded = expandedUserActions.has(originalIndex);
return (
-
>
)}
-
+
);
})}
>
@@ -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
(tool);
const { t } = useTranslation();
+ const { canEdit, canEditCredentials } = React.useContext(ToolAccessContext);
const [actionToDelete, setActionToDelete] = React.useState(
null,
);
@@ -925,9 +948,10 @@ function APIToolConfig({
{filteredActions.map(([actionName, action], actionIndex) => {
const isExpanded = expandedActions.has(actionName);
return (
-
{
setApiTool((prevApiTool) => {
const updatedActions = {
@@ -1213,7 +1238,7 @@ function APIToolConfig({
>
)}
-
+
);
})}
@@ -1249,6 +1274,7 @@ function APIActionTable({
) => void;
}) {
const { t } = useTranslation();
+ const { canEditCredentials } = React.useContext(ToolAccessContext);
const idPrefix = React.useId();
const [action, setAction] = React.useState(apiAction);
@@ -1541,10 +1567,18 @@ function APIActionTable({
handlePropertyChange(section, key, 'value', e.target.value)
}
+ {...(section === 'query_params' &&
+ param.has_value && {
+ type: 'password',
+ placeholder: t('settings.tools.savedSecretPlaceholder'),
+ })}
size="sm"
/>
@@ -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"
/>
diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx
new file mode 100644
index 00000000..caf80038
--- /dev/null
+++ b/frontend/src/settings/Tools.test.tsx
@@ -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) => {
+ const { interpolation: _i, ...rest } = opts ?? {};
+ void _i;
+ return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
+ },
+ }),
+}));
+
+vi.mock('../hooks', async (importOriginal) => ({
+ ...(await importOriginal()),
+ 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 }[];
+ }) => (
+
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ ),
+}));
+
+const toolConfigProps = vi.fn();
+vi.mock('./ToolConfig', () => ({
+ default: (props: unknown) => {
+ toolConfigProps(props);
+ return
;
+ },
+}));
+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( );
+ });
+ };
+
+ const card = (name: string) =>
+ Array.from(container.querySelectorAll('[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('[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(
+ '[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;
+ };
+ 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(
+ `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();
+ });
+});
diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx
index eb408ad4..4f3d62fa 100644
--- a/frontend/src/settings/Tools.tsx
+++ b/frontend/src/settings/Tools.tsx
@@ -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() {
-
+
+
+ {t('settings.tools.inMyChats')}
+
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() {
/>
({
+ 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;
+ }) => (
+ handleDeleteDocument(0, paginatedDocuments[0])}
+ >
+ DELETE
+
+ ),
+}));
+
+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(
+
+
+ } />
+
+ ,
+ );
+ });
+ 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' }],
+ });
+ });
+});
diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx
index 723e0061..8798edd4 100644
--- a/frontend/src/settings/index.tsx
+++ b/frontend/src/settings/index.tsx
@@ -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) {
diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts
index 76006b84..072ed6d8 100644
--- a/frontend/src/settings/types/index.ts
+++ b/frontend/src/settings/types/index.ts
@@ -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;
};
diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx
new file mode 100644
index 00000000..aa5117ee
--- /dev/null
+++ b/frontend/src/teams/ShareToTeamModal.test.tsx
@@ -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) => {
+ 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 = {},
+ 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(
+ 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(
+ '#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(
+ '#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('[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('[role="radio"]'),
+ ).find((b) => b.textContent?.includes('settings.teams.share.filter.all'));
+ act(() => allPill!.click());
+ const search = body().querySelector(
+ '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(
+ 'button[aria-label="settings.teams.share.removeAccess"]',
+ );
+ act(() => remove!.click());
+ await flush();
+ expect(body().querySelector('[role="alert"]')?.textContent).toContain(
+ 'Not allowed',
+ );
+ });
+});
diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx
index 54f4c9d7..dc464a59 100644
--- a/frontend/src/teams/ShareToTeamModal.tsx
+++ b/frontend/src/teams/ShareToTeamModal.tsx
@@ -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(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(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>(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('all');
+
// The access level applied to the next suggestion picked from the combobox.
const [accessLevel, setAccessLevel] = useState('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(
+ () => 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 (
{
+ 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 = (
+
+ {initialOf(t('settings.teams.share.you'))}
+
+ }
+ title={t('settings.teams.share.you')}
+ trailing={
+
+ {yourRole}
+
+ }
+ />
+ );
+
+ const errors = (
+ <>
+ {loadError && (
+
+
+
+ {t('settings.teams.share.loadError')}
+
+
+ )}
+ {actionError && (
+
+
+ {actionError}
+
+ )}
+ >
+ );
+
+ 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 = (
+
+
setStep('main')}
+ >
+
+ {t('settings.teams.share.back')}
+
+
+
+ {t('settings.teams.share.peopleWithAccess')}
+
+
+ {t('settings.teams.share.allSummary', {
+ interpolation: { escapeValue: false },
+ name: resourceName ?? '',
+ teams: teamGrantCount,
+ people: personGrantCount,
+ })}
+
+
+ {errors}
+
setAccessQuery(e.target.value)}
+ />
+
+
+ value && setAccessFilter(value as AccessFilter)
+ }
+ aria-label={t('settings.teams.share.filterLabel')}
+ >
+ {filterOptions.map((option) => (
+
+ {t(`settings.teams.share.filter.${option.value}`)} {option.count}
+
+ ))}
+
+
+
+ {accessFilter === 'all' && !accessQuery.trim() && youRow}
+ {filteredShares.map(renderShareRow)}
+
+ {filteredShares.length === 0 && (
+
+ {t('settings.teams.share.noMatches')}
+
+ )}
+
+ );
+
+ const accessSettings = canManageSettings && (
+
+
+ setSettingsOpen((open) => !open)}
+ >
+
+
+ {t('settings.teams.accessSettings.title')}
+
+
+
+ {settingsOpen && (
+
+ {settings.map((setting) => {
+ const copy = settingCopy(t, resourceType, setting.key);
+ const id = `share-setting-${setting.key}`;
+ return (
+
+ toggleSetting(setting.key, value)}
+ />
+
+ );
+ })}
+
+ )}
+
+ );
+
+ const mainStep = (
+
+
+
+ {t('settings.teams.share.subtitle')}
+
+ {errors}
+
+
+ {teams.length === 0 ? (
+
{t('settings.teams.share.noTeams')}
+ ) : (
+ <>
+
+ {/* Add row: type-ahead combobox + access level select. */}
+
+
+
+
+
+ {t('settings.teams.share.addPlaceholder')}
+
+
+
+
+
+
+
+ {!hasSuggestions && (
+
+ {t('settings.teams.share.noMatches')}
+
+ )}
+ {teamSuggestions.length > 0 && (
+
+ {teamSuggestions.map((suggestion) => (
+ commitSuggestion(suggestion)}
+ >
+
+ {initialOf(suggestion.teamName)}
+
+
+ {suggestion.teamName}
+
+
+ ))}
+
+ )}
+ {memberSuggestions.length > 0 && (
+
+ {memberSuggestions.map((suggestion) =>
+ suggestion.kind === 'member' ? (
+ commitSuggestion(suggestion)}
+ >
+
+ {initialOf(suggestion.label)}
+
+
+ {suggestion.label}
+
+ {' · '}
+ {suggestion.teamName}
+
+
+
+ ) : null,
+ )}
+
+ )}
+
+
+
+
+
+
setAccessLevel(value as AccessLevel)}
+ >
+
+
+
+
+
+ {t('settings.teams.share.accessLevel.viewer')}
+
+
+ {t('settings.teams.share.accessLevel.editor')}
+
+
+
+
+
+ {editorHint(t, resourceType, settings)}
+
+
+
+ {/* People with access. */}
+
+
+ {t('settings.teams.share.showAll', {
+ count: shares.length,
+ })}
+
+
+ )
+ }
+ />
+
+ {youRow}
+ {previewShares.map(renderShareRow)}
+
+ {isLongList && (
+
+ {t('settings.teams.share.andMore', {
+ count: shares.length - previewShares.length,
+ })}
+
+ )}
+
+ >
+ )}
+
+ {accessSettings}
+
+ );
+
return (
@@ -485,178 +978,7 @@ export default function ShareToTeamModal({
}
>
-
- {t('settings.teams.share.subtitle')}
-
-
- {loadError && (
-
-
-
- {t('settings.teams.share.loadError')}
-
-
- )}
- {actionError && (
-
-
- {actionError}
-
- )}
-
- {teams.length === 0 ? (
- {t('settings.teams.share.noTeams')}
- ) : (
- <>
- {/* Add row: type-ahead combobox + access level select. */}
-
-
-
-
-
- {t('settings.teams.share.addPlaceholder')}
-
-
-
-
-
-
-
- {!hasSuggestions && (
-
- {t('settings.teams.share.noMatches')}
-
- )}
- {teamSuggestions.length > 0 && (
-
- {teamSuggestions.map((suggestion) => (
- commitSuggestion(suggestion)}
- >
-
- {initialOf(suggestion.teamName)}
-
-
- {suggestion.teamName}
-
-
- ))}
-
- )}
- {memberSuggestions.length > 0 && (
-
- {memberSuggestions.map((suggestion) =>
- suggestion.kind === 'member' ? (
- commitSuggestion(suggestion)}
- >
-
- {initialOf(suggestion.label)}
-
-
- {suggestion.label}
-
- {' · '}
- {suggestion.teamName}
-
-
-
- ) : null,
- )}
-
- )}
-
-
-
-
-
-
setAccessLevel(value as AccessLevel)}
- >
-
-
-
-
-
- {t('settings.teams.share.accessLevel.viewer')}
-
-
- {t('settings.teams.share.accessLevel.editor')}
-
-
-
-
-
- {/* People with access. */}
-
-
-
- {/* Owner — pinned, non-interactive. */}
-
- {initialOf(t('settings.teams.share.you'))}
-
- }
- title={t('settings.teams.share.you')}
- trailing={
-
- {t('settings.teams.share.owner')}
-
- }
- />
- {shares.map(renderShareRow)}
-
-
- >
- )}
+ {step === 'all' ? allStep : mainStep}
);
}
diff --git a/frontend/src/teams/accessSettings.ts b/frontend/src/teams/accessSettings.ts
new file mode 100644
index 00000000..02caa3c4
--- /dev/null
+++ b/frontend/src/teams/accessSettings.ts
@@ -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;
diff --git a/frontend/src/teams/teamsSlice.test.ts b/frontend/src/teams/teamsSlice.test.ts
new file mode 100644
index 00000000..ac4f6ffa
--- /dev/null
+++ b/frontend/src/teams/teamsSlice.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/teams/teamsSlice.ts b/frontend/src/teams/teamsSlice.ts
index 1165f663..07946cc7 100644
--- a/frontend/src/teams/teamsSlice.ts
+++ b/frontend/src/teams/teamsSlice.ts
@@ -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(
'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;
});
diff --git a/frontend/src/utils/accessUtils.test.ts b/frontend/src/utils/accessUtils.test.ts
new file mode 100644
index 00000000..9b3b8e2e
--- /dev/null
+++ b/frontend/src/utils/accessUtils.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/utils/accessUtils.ts b/frontend/src/utils/accessUtils.ts
new file mode 100644
index 00000000..1fbc54de
--- /dev/null
+++ b/frontend/src/utils/accessUtils.ts
@@ -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, ReadonlySet> = {
+ 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);
+}
diff --git a/frontend/src/utils/toolUtils.test.ts b/frontend/src/utils/toolUtils.test.ts
index f9b5dccf..a389cd72 100644
--- a/frontend/src/utils/toolUtils.test.ts
+++ b/frontend/src/utils/toolUtils.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/src/utils/toolUtils.ts b/frontend/src/utils/toolUtils.ts
index 72f0feac..3515fd07 100644
--- a/frontend/src/utils/toolUtils.ts
+++ b/frontend/src/utils/toolUtils.ts
@@ -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);
diff --git a/tests/agents/test_default_tools.py b/tests/agents/test_default_tools.py
index 472698ea..29bd9a3a 100644
--- a/tests/agents/test_default_tools.py
+++ b/tests/agents/test_default_tools.py
@@ -502,6 +502,9 @@ class TestFkBoundToolsIsInSync:
"notes": "notes",
"todos": "todo_list",
}
+ # FK-bound tables that are not a tool's storage: per-user settings keyed
+ # by a tool row, never written by a running tool.
+ _NOT_TOOL_STORAGE = {"user_tool_preferences"}
def test_fk_bound_tools_matches_metadata(self):
from docsgpt.storage.db.models import metadata
@@ -509,7 +512,7 @@ class TestFkBoundToolsIsInSync:
fk_bound_tables = set()
for tbl in metadata.tables.values():
tool_id_col = tbl.columns.get("tool_id")
- if tool_id_col is None:
+ if tool_id_col is None or tbl.name in self._NOT_TOOL_STORAGE:
continue
for fk in tool_id_col.foreign_keys:
if fk.target_fullname == "user_tools.id":
diff --git a/tests/agents/test_headless_runner_prompt.py b/tests/agents/test_headless_runner_prompt.py
new file mode 100644
index 00000000..1abb2c0f
--- /dev/null
+++ b/tests/agents/test_headless_runner_prompt.py
@@ -0,0 +1,76 @@
+"""``run_agent_headless`` renders only a prompt the agent's owner may use.
+
+Scheduled and webhook runs execute as the owner; a prompt the owner lost
+access to (a revoked team grant, a deleted prompt) falls back to the default.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+PROMPT_ID = "11111111-1111-1111-1111-111111111111"
+
+
+def _rendered_prompt_id(monkeypatch, *, usable: bool) -> str:
+ from docsgpt.agents import headless_runner as hr
+ from docsgpt.api.user.resource_access import build
+
+ agent = MagicMock(name="agent")
+ agent.gen.return_value = iter([{"answer": "ok"}])
+ agent.llm.token_usage = {"prompt_tokens": 1, "generated_tokens": 1}
+ retriever = MagicMock(name="retriever")
+ retriever.search.return_value = []
+ tool_executor = MagicMock(name="tool_executor")
+ tool_executor.headless_denials = []
+ rendered: list = []
+
+ def _get_prompt(pid):
+ rendered.append(pid)
+ return "system prompt"
+
+ seen: list = []
+
+ def _resolve(_conn, resource_type, resource_id, user_id):
+ seen.append((resource_type, resource_id, user_id))
+ return build(resource_type, resource_id, "viewer", "x", {}) if usable else None
+
+ @contextmanager
+ def _conn():
+ yield MagicMock()
+
+ monkeypatch.setattr(hr, "get_prompt", _get_prompt)
+ monkeypatch.setattr(
+ hr.RetrieverCreator, "create_retriever",
+ classmethod(lambda cls, *a, **kw: retriever),
+ )
+ monkeypatch.setattr(hr, "ToolExecutor", lambda *a, **kw: tool_executor)
+ monkeypatch.setattr(
+ hr.AgentCreator, "create_agent", classmethod(lambda cls, *a, **kw: agent),
+ )
+ config = {
+ "user_id": "owner-1", "id": "agent-1", "default_model_id": "m",
+ "prompt_id": PROMPT_ID,
+ }
+ with patch("docsgpt.core.model_utils.validate_model_id", return_value=True), \
+ patch("docsgpt.core.model_utils.get_default_model_id", return_value="m"), \
+ patch("docsgpt.core.model_utils.get_provider_from_model_id", return_value="openai"), \
+ patch("docsgpt.core.model_utils.get_api_key_for_provider", return_value="k"), \
+ patch("docsgpt.utils.calculate_doc_token_budget", return_value=1000), \
+ patch("docsgpt.api.user.resource_access.resolve", _resolve), \
+ patch("docsgpt.api.answer.services.stream_processor.db_readonly", _conn):
+ hr.run_agent_headless(config, "do the thing")
+ # Checked as the agent's owner, not whoever scheduled the run.
+ assert seen == [("prompt", PROMPT_ID, "owner-1")]
+ return rendered[0]
+
+
+@pytest.mark.unit
+class TestHeadlessRunnerPromptAccess:
+ def test_usable_prompt_is_rendered(self, monkeypatch):
+ assert _rendered_prompt_id(monkeypatch, usable=True) == PROMPT_ID
+
+ def test_unusable_prompt_falls_back_to_default(self, monkeypatch):
+ assert _rendered_prompt_id(monkeypatch, usable=False) == "default"
diff --git a/tests/agents/test_tool_access_runtime.py b/tests/agents/test_tool_access_runtime.py
new file mode 100644
index 00000000..7d6444ab
--- /dev/null
+++ b/tests/agents/test_tool_access_runtime.py
@@ -0,0 +1,155 @@
+"""Runtime resolution of team-shared tools (owner context, live grant checks)."""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import contextmanager
+from unittest.mock import Mock
+
+import pytest
+
+from docsgpt.agents.default_tools import resolve_tool_by_id
+from docsgpt.agents.tool_executor import ToolExecutor
+from docsgpt.api.user.resource_access import set_settings
+from docsgpt.security.encryption import encrypt_credentials
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+from docsgpt.storage.db.repositories.user_tool_preferences import (
+ UserToolPreferencesRepository,
+)
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+
+OWNER = "alice"
+
+
+def _tool(conn, name="read_webpage", config=None, status=True):
+ return UserToolsRepository(conn).create(
+ OWNER, name, config=config or {}, display_name=name, description="",
+ actions=[{"name": "act", "active": True}], status=status,
+ )
+
+
+def _share(conn, tool_id, member, level="viewer"):
+ team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
+ TeamMembersRepository(conn).add_member(str(team["id"]), member)
+ TeamResourceGrantsRepository(conn).grant(
+ str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level
+ )
+ return team
+
+
+def _revoke(conn, team, tool_id):
+ TeamResourceGrantsRepository(conn).revoke(str(team["id"]), "tool", str(tool_id))
+
+
+@pytest.fixture
+def use_conn(monkeypatch, pg_conn):
+ @contextmanager
+ def _yield():
+ yield pg_conn
+
+ monkeypatch.setattr("docsgpt.agents.tool_executor.db_readonly", _yield)
+ return pg_conn
+
+
+class TestResolveToolById:
+ def test_owner_resolves(self, pg_conn):
+ tool = _tool(pg_conn)
+ row = resolve_tool_by_id(str(tool["id"]), OWNER, user_tools_repo=UserToolsRepository(pg_conn))
+ assert row is not None and row["user_id"] == OWNER
+
+ def test_grantee_with_use_in_own_resolves_owner_row(self, pg_conn):
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "bob")
+ row = resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=UserToolsRepository(pg_conn))
+ assert row is not None and row["user_id"] == OWNER
+
+ def test_viewer_without_use_in_own_does_not_resolve(self, pg_conn):
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "bob")
+ set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
+ repo = UserToolsRepository(pg_conn)
+ assert resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=repo) is None
+ # Editors keep use_in_own when the viewer switch is off.
+ _share(pg_conn, tool["id"], "carol", "editor")
+ assert resolve_tool_by_id(str(tool["id"]), "carol", user_tools_repo=repo) is not None
+
+ def test_stranger_does_not_resolve(self, pg_conn):
+ tool = _tool(pg_conn)
+ assert resolve_tool_by_id(str(tool["id"]), "eve", user_tools_repo=UserToolsRepository(pg_conn)) is None
+
+
+class TestAgentToolsForOwner:
+ def _agent(self, conn, owner, tool_ids):
+ key = f"k-{uuid.uuid4().hex[:8]}"
+ AgentsRepository(conn).create(owner, "A", "published", key=key, tools=[str(t) for t in tool_ids])
+ return key
+
+ def test_team_tool_on_grantees_agent_resolves_then_drops_on_revoke(self, use_conn):
+ tool = _tool(use_conn)
+ team = _share(use_conn, tool["id"], "bob")
+ key = self._agent(use_conn, "bob", [tool["id"]])
+ # Carol chats with Bob's agent: tools resolve under Bob, the agent owner.
+ tools = ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key)
+ assert str(tool["id"]) in tools
+ assert tools[str(tool["id"])]["user_id"] == OWNER
+ _revoke(use_conn, team, tool["id"])
+ assert ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key) == {}
+
+ def test_explicit_ids_path_checks_the_caller(self, use_conn):
+ tool = _tool(use_conn)
+ executor = ToolExecutor(user="bob")
+ assert executor._get_tools_by_ids([str(tool["id"])]) == {}
+ _share(use_conn, tool["id"], "bob")
+ assert str(tool["id"]) in executor._get_tools_by_ids([str(tool["id"])])
+
+
+class TestAgentlessChatTools:
+ def test_includes_in_chat_team_tools(self, use_conn):
+ own = _tool(use_conn, name="brave")
+ shared = _tool(use_conn, name="read_webpage")
+ team = _share(use_conn, shared["id"], "bob")
+ UserToolsRepository(use_conn).create("bob", "cryptoprice", status=True)
+ names = lambda: sorted( # noqa: E731
+ t["name"] for t in ToolExecutor(user="bob")._get_user_tools("bob").values() if t.get("user_id")
+ )
+ assert names() == ["cryptoprice"] # sharing alone never adds to chats
+ UserToolPreferencesRepository(use_conn).set_in_chat("bob", str(shared["id"]), True)
+ assert names() == ["cryptoprice", "read_webpage"]
+ set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": False}, OWNER)
+ assert names() == ["cryptoprice"]
+ set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": True}, OWNER)
+ _revoke(use_conn, team, shared["id"])
+ assert names() == ["cryptoprice"]
+ assert own["id"] # the owner's own tool never leaks into bob's chat
+
+
+class TestRuntimeCredentials:
+ def test_mcp_tool_loads_with_owner_identity(self, monkeypatch):
+ executor = ToolExecutor(user="bob")
+ mock_tm = Mock()
+ monkeypatch.setattr("docsgpt.agents.tool_executor.ToolManager", lambda config: mock_tm)
+ tool_data = {"id": str(uuid.uuid4()), "name": "mcp_tool", "user_id": OWNER, "config": {}}
+ executor._get_or_load_tool(tool_data, "t1", "act")
+ assert mock_tm.load_tool.call_args.kwargs["user_id"] == OWNER
+
+ def test_api_tool_secret_values_decrypt_with_owner(self):
+ from docsgpt.agents.tool_executor import api_tool_action_with_secrets
+
+ blob = encrypt_credentials({"a": {"headers": {"X-Key": "sk"}}}, OWNER)
+ tool_data = {
+ "user_id": OWNER,
+ "config": {
+ "encrypted_action_secrets": blob,
+ "actions": {"a": {"url": "https://x", "headers": {"properties": {
+ "X-Key": {"value": "", "has_value": True, "filled_by_llm": False}}}}},
+ },
+ }
+ action = api_tool_action_with_secrets(tool_data, "a", "bob")
+ assert action["headers"]["properties"]["X-Key"]["value"] == "sk"
+ # The stored row is not mutated.
+ assert tool_data["config"]["actions"]["a"]["headers"]["properties"]["X-Key"]["value"] == ""
diff --git a/tests/agents/test_tool_executor_headless.py b/tests/agents/test_tool_executor_headless.py
index ae3c1dc5..bf48cca6 100644
--- a/tests/agents/test_tool_executor_headless.py
+++ b/tests/agents/test_tool_executor_headless.py
@@ -148,6 +148,11 @@ class TestHeadlessSchedulerExclusion:
"get": lambda _self, _u: None,
})(),
)
+ # No team tools switched into chats either.
+ monkeypatch.setattr(
+ te_module.ToolExecutor, "_shared_in_chat_tools",
+ staticmethod(lambda _conn, _user: []),
+ )
sched_id = default_tool_id("scheduler")
diff --git a/tests/agents/tools/test_wiki.py b/tests/agents/tools/test_wiki.py
index db3d21d8..a830b5be 100644
--- a/tests/agents/tools/test_wiki.py
+++ b/tests/agents/tools/test_wiki.py
@@ -152,6 +152,17 @@ def patched_wiki(monkeypatch, reembed_mock, rebuild_mock):
task = MagicMock()
task.delay = reembed_mock
monkeypatch.setattr("docsgpt.api.user.tasks.reembed_wiki_page", task)
+ # Every write re-checks the caller's live grant; default to editor.
+ monkeypatch.setattr(
+ "docsgpt.api.user.resource_access.resolve",
+ lambda conn, rt, rid, uid: _access("editor"),
+ )
+
+
+def _access(level):
+ from docsgpt.api.user.resource_access import build
+
+ return build("source", "src-1", level, "owner-sub", {})
@pytest.fixture
@@ -194,6 +205,54 @@ class TestBasics:
assert wiki_tool.updated_by == "caller-sub"
+# =====================================================================
+# Live grant re-check on writes
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestLiveWriteCheck:
+ @pytest.mark.parametrize("revoked", [None, "viewer"])
+ def test_revoked_or_viewer_cannot_write(self, wiki_tool, monkeypatch, reembed_mock, revoked):
+ wiki_tool.execute_action("create", path="/a.md", content="one")
+ monkeypatch.setattr(
+ "docsgpt.api.user.resource_access.resolve",
+ lambda conn, rt, rid, uid: _access(revoked) if revoked else None,
+ )
+ reembed_mock.reset_mock()
+ for action, kwargs in (
+ ("create", {"path": "/b.md", "content": "x"}),
+ ("str_replace", {"path": "/a.md", "old_str": "one", "new_str": "two"}),
+ ("insert", {"path": "/a.md", "insert_line": 1, "insert_text": "x"}),
+ ("delete", {"path": "/a.md"}),
+ ("rename", {"old_path": "/a.md", "new_path": "/c.md"}),
+ ):
+ result = wiki_tool.execute_action(action, **kwargs)
+ assert "no longer have edit access" in result, action
+ reembed_mock.assert_not_called()
+ # Reads still work.
+ assert "one" in wiki_tool.execute_action("view", path="/a.md")
+
+ def test_checks_the_invoking_user(self, wiki_tool, monkeypatch):
+ seen = []
+
+ def _resolve(conn, rt, rid, uid):
+ seen.append((rt, rid, uid))
+ return _access("editor")
+
+ monkeypatch.setattr("docsgpt.api.user.resource_access.resolve", _resolve)
+ wiki_tool.execute_action("create", path="/a.md", content="x")
+ assert seen == [("source", "src-1", "caller-sub")]
+
+ def test_no_caller_denied(self, patched_wiki):
+ from docsgpt.agents.tools.wiki import WikiTool
+
+ tool = WikiTool({"source_id": "src-1", "source_owner_id": "owner-sub"})
+ assert "no longer have edit access" in tool.execute_action(
+ "create", path="/a.md", content="x"
+ )
+
+
# =====================================================================
# create / view
# =====================================================================
@@ -498,8 +557,8 @@ class TestBuildAgentGating:
_noop_conn,
)
monkeypatch.setattr(
- "docsgpt.api.user.team_sharing.effective_write_owner",
- lambda conn, rt, rid, uid: "owner-x",
+ "docsgpt.api.answer.services.stream_processor._wiki_write_owner",
+ lambda conn, sid, uid: "owner-x",
)
cfg = proc._build_wiki_config()
assert cfg is not None
@@ -524,10 +583,10 @@ class TestBuildAgentGating:
"docsgpt.api.answer.services.stream_processor.db_readonly",
_noop_conn,
)
- # Viewer: effective_write_owner returns None.
+ # Viewer: no ``edit`` on the source, so no write owner.
monkeypatch.setattr(
- "docsgpt.api.user.team_sharing.effective_write_owner",
- lambda conn, rt, rid, uid: None,
+ "docsgpt.api.answer.services.stream_processor._wiki_write_owner",
+ lambda conn, sid, uid: None,
)
assert proc._build_wiki_config() is None
@@ -550,8 +609,8 @@ class TestBuildAgentGating:
_noop_conn,
)
monkeypatch.setattr(
- "docsgpt.api.user.team_sharing.effective_write_owner",
- lambda conn, rt, rid, uid: "owner-x",
+ "docsgpt.api.answer.services.stream_processor._wiki_write_owner",
+ lambda conn, sid, uid: "owner-x",
)
assert proc._build_wiki_config() is None
@@ -574,8 +633,8 @@ class TestBuildAgentGating:
_noop_conn,
)
monkeypatch.setattr(
- "docsgpt.api.user.team_sharing.effective_write_owner",
- lambda conn, rt, rid, uid: "owner-x",
+ "docsgpt.api.answer.services.stream_processor._wiki_write_owner",
+ lambda conn, sid, uid: "owner-x",
)
cfg = proc._build_wiki_config()
assert cfg is not None
diff --git a/tests/api/answer/services/test_stream_processor_access.py b/tests/api/answer/services/test_stream_processor_access.py
new file mode 100644
index 00000000..1ac58ede
--- /dev/null
+++ b/tests/api/answer/services/test_stream_processor_access.py
@@ -0,0 +1,118 @@
+"""Prompt and wiki-tool access in the answer pipeline (live grant checks)."""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import contextmanager
+
+import pytest
+
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.sources import SourcesRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+OWNER = "alice"
+
+
+@pytest.fixture
+def use_conn(monkeypatch, pg_conn):
+ @contextmanager
+ def _yield():
+ yield pg_conn
+
+ monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.db_readonly", _yield)
+ return pg_conn
+
+
+def _share(conn, rtype, rid, member, level="viewer"):
+ team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
+ TeamMembersRepository(conn).add_member(str(team["id"]), member)
+ TeamResourceGrantsRepository(conn).grant(str(team["id"]), rtype, str(rid), OWNER, OWNER, access_level=level)
+ return team
+
+
+def _processor(data, caller):
+ from docsgpt.api.answer.services.stream_processor import StreamProcessor
+
+ return StreamProcessor(data, {"sub": caller})
+
+
+class TestAgentlessPrompt:
+ def test_own_prompt_kept(self, use_conn):
+ pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
+ proc = _processor({"question": "q", "prompt_id": pid}, OWNER)
+ proc._configure_agent()
+ assert proc.agent_config["prompt_id"] == pid
+
+ def test_shared_prompt_kept_then_falls_back_when_revoked(self, use_conn):
+ pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
+ team = _share(use_conn, "prompt", pid, "bob")
+ proc = _processor({"question": "q", "prompt_id": pid}, "bob")
+ proc._configure_agent()
+ assert proc.agent_config["prompt_id"] == pid
+ TeamResourceGrantsRepository(use_conn).revoke(str(team["id"]), "prompt", pid)
+ proc = _processor({"question": "q", "prompt_id": pid}, "bob")
+ proc._configure_agent()
+ assert proc.agent_config["prompt_id"] == "default"
+
+ def test_strangers_prompt_falls_back_to_default(self, use_conn):
+ pid = str(PromptsRepository(use_conn).create(OWNER, "P", "secret")["id"])
+ proc = _processor({"question": "q", "prompt_id": pid}, "eve")
+ proc._configure_agent()
+ assert proc.agent_config["prompt_id"] == "default"
+
+ def test_presets_pass_through_without_db(self):
+ proc = _processor({"question": "q", "prompt_id": "creative"}, "eve")
+ proc._configure_agent()
+ assert proc.agent_config["prompt_id"] == "creative"
+
+
+class TestAgentPrompt:
+ def _run(self, monkeypatch, agent_owner, prompt_id, caller="carol"):
+ from docsgpt.api.answer.services.stream_processor import StreamProcessor
+
+ monkeypatch.setattr(
+ StreamProcessor, "_get_data_from_api_key",
+ lambda self, key: {"prompt_id": prompt_id, "user": agent_owner, "_id": None},
+ )
+ proc = _processor({"question": "q", "api_key": "k"}, caller)
+ proc._configure_agent()
+ return proc.agent_config["prompt_id"]
+
+ def test_prompt_checked_against_agent_owner(self, use_conn, monkeypatch):
+ pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
+ # Bob's agent uses Alice's prompt: allowed only while Bob has ``use``.
+ assert self._run(monkeypatch, "bob", pid) == "default"
+ _share(use_conn, "prompt", pid, "bob")
+ assert self._run(monkeypatch, "bob", pid) == pid
+ assert self._run(monkeypatch, OWNER, pid) == pid
+
+ def test_deleted_prompt_falls_back(self, use_conn, monkeypatch):
+ assert self._run(monkeypatch, OWNER, str(uuid.uuid4())) == "default"
+
+
+class TestWikiConfigAccess:
+ def _wiki(self, conn):
+ return SourcesRepository(conn).create("W", user_id=OWNER, config={"kind": "wiki"})
+
+ def _cfg(self, conn, caller, sid):
+ from docsgpt.api.answer.services.stream_processor import StreamProcessor
+
+ proc = StreamProcessor.__new__(StreamProcessor)
+ proc.all_sources = [{"id": sid}]
+ proc.decoded_token = {"sub": caller}
+ return proc._build_wiki_config()
+
+ def test_owner_and_editor_get_tool_viewer_does_not(self, use_conn):
+ sid = str(self._wiki(use_conn)["id"])
+ assert self._cfg(use_conn, OWNER, sid)["source_owner_id"] == OWNER
+ _share(use_conn, "source", sid, "ed", "editor")
+ _share(use_conn, "source", sid, "vi", "viewer")
+ cfg = self._cfg(use_conn, "ed", sid)
+ assert cfg["source_owner_id"] == OWNER and cfg["user"] == "ed"
+ assert self._cfg(use_conn, "vi", sid) is None
+ assert self._cfg(use_conn, "eve", sid) is None
diff --git a/tests/api/test_agent_team_sharing.py b/tests/api/test_agent_team_sharing.py
index 26a4f284..624bfcc0 100644
--- a/tests/api/test_agent_team_sharing.py
+++ b/tests/api/test_agent_team_sharing.py
@@ -27,6 +27,34 @@ def _cm(value):
yield value
+def _access_patches(sub, repo, team_access):
+ """Stand in for ``resource_access``: owner when the repo owns the row,
+ the team level when given, else not visible."""
+ from docsgpt.api.user.resource_access import AccessDenied, build
+
+ def _resolve(_conn, resource_type, resource_id, user_id):
+ if repo.get_any.return_value:
+ return build(resource_type, resource_id, "owner", user_id, {})
+ if team_access:
+ return build(resource_type, resource_id, team_access, "owner-x", {})
+ return None
+
+ def _require(conn, resource_type, resource_id, user_id, action):
+ ra = _resolve(conn, resource_type, resource_id, user_id)
+ if ra is None:
+ raise AccessDenied(404, "Agent not found")
+ if not ra.can(action):
+ raise AccessDenied(403, "Your access to this item doesn't allow that")
+ return ra
+
+ if repo.get_any.return_value and isinstance(repo.get_by_id.return_value, Mock):
+ repo.get_by_id.return_value = repo.get_any.return_value
+ return [
+ patch("docsgpt.api.user.agents.routes.resolve", _resolve),
+ patch("docsgpt.api.user.agents.routes.require", _require),
+ ]
+
+
def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_details=None):
if source_details is None:
source_details = []
@@ -35,7 +63,7 @@ def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_de
patch("docsgpt.app.resolve_roles", return_value=["user"]),
patch("docsgpt.api.user.agents.routes.db_readonly", lambda: _cm(Mock())),
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
- patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
+ *_access_patches(sub, repo, team_access),
# Resolve names by id (owner-agnostic) — patched so the test never
# touches the DB; the route is what we're asserting wires them in.
patch(
@@ -70,7 +98,7 @@ class TestGetAgentTeamFallback:
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["team_access"] is None
- repo.get_by_id.assert_not_called()
+ assert data["access"] == "owner"
def test_team_member_sees_shared_agent(self, client):
aid = str(uuid.uuid4())
@@ -92,7 +120,8 @@ class TestGetAgentTeamFallback:
# Owner's public share token / API key are blanked for grantees.
assert data["shared_token"] == ""
assert data.get("key", "") == ""
- # get_by_id only reached AFTER the team grant check.
+ assert data["access"] == "viewer"
+ # get_by_id only reached AFTER the access check.
repo.get_by_id.assert_called_once_with(aid)
def test_no_access_returns_404(self, client):
@@ -164,7 +193,7 @@ def _update_patches(sub, repo, team_access, can_access_mock):
patch("docsgpt.app.resolve_roles", return_value=["user"]),
patch("docsgpt.api.user.agents.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
- patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
+ *_access_patches(sub, repo, team_access),
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
]
diff --git a/tests/api/test_connector_routes_happy.py b/tests/api/test_connector_routes_happy.py
index 141f87b0..e1302399 100644
--- a/tests/api/test_connector_routes_happy.py
+++ b/tests/api/test_connector_routes_happy.py
@@ -484,17 +484,35 @@ class TestConnectorSync:
r = ConnectorSync().post()
assert r.status_code == 401
- def test_returns_400_missing_fields(self, app):
+ def test_returns_400_missing_source_id(self, app):
from docsgpt.api.connector.routes import ConnectorSync
with app.test_request_context(
- "/api/connectors/sync", method="POST", json={"source_id": "x"}
+ "/api/connectors/sync", method="POST", json={"session_token": "y"}
):
from flask import request
request.decoded_token = {"sub": "u"}
r = ConnectorSync().post()
assert r.status_code == 400
+ def test_owner_without_session_token_returns_400(self, app, pg_conn):
+ # The owner syncs with their own session token; team editors don't
+ # send one (their sync uses the owner's session).
+ from docsgpt.api.connector.routes import ConnectorSync
+ from docsgpt.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-sync-notoken"
+ src = SourcesRepository(pg_conn).create(
+ "s", user_id=user, remote_data={"provider": "github"}
+ )
+ with _patch_db(pg_conn), app.test_request_context(
+ "/api/connectors/sync", method="POST", json={"source_id": str(src["id"])}
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ r = ConnectorSync().post()
+ assert r.status_code == 400
+
def test_returns_404_source_not_found(self, app, pg_conn):
from docsgpt.api.connector.routes import ConnectorSync
diff --git a/tests/api/test_data_plane_audit.py b/tests/api/test_data_plane_audit.py
index b298917b..4647242e 100644
--- a/tests/api/test_data_plane_audit.py
+++ b/tests/api/test_data_plane_audit.py
@@ -56,13 +56,16 @@ _CONVERSATIONS = "docsgpt.api.user.conversations.routes"
@pytest.mark.unit
class TestSourceAudit:
def test_delete_records_source_deleted(self, client):
- repo = Mock()
- repo.get_any.return_value = {"id": "src-1", "name": "Handbook"}
+ from docsgpt.api.user.resource_access import build
+
+ doc = {"id": "src-1", "name": "Handbook"}
+ ra = build("source", "src-1", "owner", "u1", {})
storage = Mock()
storage.file_exists.return_value = False
with _authed(
_SOURCES,
- SourcesRepository=Mock(return_value=repo),
+ load_source=Mock(return_value=(doc, ra)),
+ SourcesRepository=Mock(return_value=Mock()),
StorageCreator=Mock(get_storage=Mock(return_value=storage)),
) as recorded:
resp = client.get("/api/delete_old?source_id=src-1")
@@ -72,9 +75,12 @@ class TestSourceAudit:
assert recorded[0][3]["name"] == "Handbook"
def test_nothing_recorded_when_the_source_is_missing(self, client):
- repo = Mock()
- repo.get_any.return_value = None
- with _authed(_SOURCES, SourcesRepository=Mock(return_value=repo)) as recorded:
+ from docsgpt.api.user.resource_access import AccessDenied
+
+ with _authed(
+ _SOURCES,
+ load_source=Mock(side_effect=AccessDenied(404, "Source not found")),
+ ) as recorded:
resp = client.get("/api/delete_old?source_id=nope")
assert resp.status_code == 404
assert recorded == []
@@ -158,8 +164,10 @@ class TestRemoteSourceAudit:
@pytest.mark.unit
class TestAgentAudit:
def test_delete_records_agent_deleted(self, client):
+ from docsgpt.api.user.resource_access import build
+
repo = Mock()
- repo.get_any.return_value = {
+ repo.get_by_id.return_value = {
"id": "agent-1",
"name": "Support bot",
"agent_type": "classic",
@@ -169,6 +177,8 @@ class TestAgentAudit:
AgentsRepository=Mock(return_value=repo),
WorkflowsRepository=Mock(),
UsersRepository=Mock(),
+ require=Mock(return_value=build("agent", "agent-1", "owner", "u1", {})),
+ delete_settings=Mock(),
) as recorded:
resp = client.delete("/api/delete_agent?id=agent-1")
assert resp.status_code == 200
@@ -177,9 +187,14 @@ class TestAgentAudit:
assert recorded[0][3]["name"] == "Support bot"
def test_missing_agent_records_nothing(self, client):
+ from docsgpt.api.user.resource_access import AccessDenied
+
repo = Mock()
- repo.get_any.return_value = None
- with _authed(_AGENTS, AgentsRepository=Mock(return_value=repo)) as recorded:
+ with _authed(
+ _AGENTS,
+ AgentsRepository=Mock(return_value=repo),
+ require=Mock(side_effect=AccessDenied(404, "Agent not found")),
+ ) as recorded:
assert client.delete("/api/delete_agent?id=x").status_code == 404
assert recorded == []
diff --git a/tests/api/test_teams_endpoints.py b/tests/api/test_teams_endpoints.py
index f8fec458..378b43b7 100644
--- a/tests/api/test_teams_endpoints.py
+++ b/tests/api/test_teams_endpoints.py
@@ -3,7 +3,7 @@
These drive the real app.py chokepoint + team_authz plane (only handle_auth /
resolve_roles and the leaf repos are mocked), so they catch regressions in the
authorization wiring: who can read/manage a team, that team_id comes from the
-URL path (never the body), and that sharing requires resource ownership. Data
+URL path (never the body), and that sharing requires the ``share`` action. Data
correctness lives in tests/storage/db/repositories/test_teams.py.
"""
@@ -15,6 +15,11 @@ from unittest.mock import Mock, patch
import pytest
+from docsgpt.api.user.resource_access import AccessDenied, build
+
+# What ``require(..., "share")`` returns for the resource owner.
+_OWNER_RA = build("agent", "22222222-2222-2222-2222-222222222222", "owner", "alice", {})
+
@pytest.fixture
def client():
@@ -234,10 +239,13 @@ class TestTeamAccessControl:
@pytest.mark.unit
class TestSharingAuthz:
- def test_share_requires_ownership(self, client):
+ def test_share_requires_share_action(self, client):
patches = _auth(sub="bob", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=False),
+ patch(
+ "docsgpt.api.user.teams.routes.require",
+ side_effect=AccessDenied(403, "no share"),
+ ),
]
_apply(patches)
try:
@@ -257,7 +265,7 @@ class TestSharingAuthz:
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
@@ -299,7 +307,7 @@ class TestSharingAuthz:
members_repo.is_member.return_value = False
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
@@ -326,7 +334,7 @@ class TestSharingAuthz:
grants_repo.grant.return_value = {"id": "g1", "target_user_id": "bob"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
@@ -357,7 +365,7 @@ class TestSharingAuthz:
# rejected cleanly, not cast-and-poison the txn into a generic error.
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
]
_apply(patches)
try:
@@ -480,7 +488,7 @@ class TestTeamNotifications:
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
@@ -531,7 +539,7 @@ class TestTeamNotifications:
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
- patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
+ patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
diff --git a/tests/api/user/agents/test_roles_access.py b/tests/api/user/agents/test_roles_access.py
new file mode 100644
index 00000000..80ddb1aa
--- /dev/null
+++ b/tests/api/user/agents/test_roles_access.py
@@ -0,0 +1,703 @@
+"""Role-based access (owner / editor / viewer / stranger) on the agent routes.
+
+Every per-agent endpoint answers through ``resource_access.require``: 404 when
+the caller can't see the agent, 403 when their role can't do the action, and
+writes land as the agent's owner. Uses real repositories on ``pg_conn``.
+"""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import ExitStack, contextmanager
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+
+from docsgpt.api.user.resource_access import set_settings, settings_for
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+OWNER, EDITOR, VIEWER, STRANGER = "r-owner", "r-editor", "r-viewer", "r-stranger"
+
+_DB_MODULES = (
+ "docsgpt.api.user.agents.routes",
+ "docsgpt.api.user.agents.sharing",
+ "docsgpt.api.user.agents.webhooks",
+ "docsgpt.api.user.agents.guardrails",
+ "docsgpt.api.user.agents.folders",
+ "docsgpt.api.user.agents.portability",
+ "docsgpt.api.user.schedules.routes",
+ "docsgpt.api.user.workflows.routes",
+ "docsgpt.api.user.analytics.routes",
+ "docsgpt.api.user.base",
+)
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ import importlib
+
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with ExitStack() as stack:
+ for mod_name in _DB_MODULES:
+ mod = importlib.import_module(mod_name)
+ for attr in ("db_session", "db_readonly"):
+ if hasattr(mod, attr):
+ stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
+ yield
+
+
+def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
+ kwargs = {"method": method.upper()}
+ if json is not None:
+ kwargs["json"] = json
+ with _patch_db(conn), app.test_request_context(path, **kwargs):
+ from flask import request
+
+ request.decoded_token = {"sub": user} if user else None
+ return getattr(resource_cls(), method.lower())(*args)
+
+
+def _status(resp) -> int:
+ return resp[1] if isinstance(resp, tuple) else resp.status_code
+
+
+def _json(resp):
+ return resp[0] if isinstance(resp, tuple) else resp.get_json()
+
+
+def _team_share(conn, resource_type, resource_id, owner=OWNER):
+ """Share with a fresh team: EDITOR as editor, VIEWER as viewer."""
+ team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", owner)
+ tid = str(team["id"])
+ members = TeamMembersRepository(conn)
+ grants = TeamResourceGrantsRepository(conn)
+ for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
+ members.add_member(tid, member)
+ grants.grant(tid, resource_type, resource_id, owner, owner,
+ access_level=level, target_user_id=member)
+ return tid
+
+
+def _agent(conn, *, status="published", key="abcd1234wxyz", share=True, **extra):
+ extra.setdefault("agent_type", "classic")
+ extra.setdefault("chunks", 6)
+ row = AgentsRepository(conn).create(
+ OWNER, "Shared", status, description="d", key=key, **extra
+ )
+ agent_id = str(row["id"])
+ if share:
+ _team_share(conn, "agent", agent_id)
+ return agent_id
+
+
+def _row(conn, agent_id):
+ return AgentsRepository(conn).get_by_id(agent_id)
+
+
+# ---------------------------------------------------------------------------
+# get_agent / get_agents
+# ---------------------------------------------------------------------------
+
+
+class TestGetAgent:
+ def _get(self, app, conn, agent_id, user):
+ from docsgpt.api.user.agents.routes import GetAgent
+
+ return _call(app, conn, GetAgent, "get", f"/api/get_agent?id={agent_id}", user)
+
+ def test_owner_gets_owner_access_and_masked_key(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ data = _json(self._get(app, pg_conn, agent_id, OWNER))
+ assert data["access"] == "owner"
+ assert "delete" in data["allowed_actions"]
+ assert data["key"] == "abcd...wxyz"
+
+ def test_editor_gets_editor_access_and_masked_key(self, app, pg_conn):
+ agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
+ resp = self._get(app, pg_conn, agent_id, EDITOR)
+ assert _status(resp) == 200
+ data = _json(resp)
+ assert data["access"] == "editor"
+ assert data["allowed_actions"] == sorted(data["allowed_actions"])
+ assert "edit" in data["allowed_actions"]
+ assert "delete" not in data["allowed_actions"]
+ assert data["key"] == "abcd...wxyz"
+ assert data["config"] == {"guardrails": {"enabled": True}}
+ assert data["ownership"] == "team"
+
+ def test_editor_without_access_details_gets_no_key(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ set_settings(pg_conn, "agent", agent_id,
+ {"editors_can_manage_access_details": False}, OWNER)
+ data = _json(self._get(app, pg_conn, agent_id, EDITOR))
+ assert "key" not in data or data["key"] == ""
+ assert data["shared_token"] == ""
+
+ def test_viewer_reads_for_chat_without_secrets_or_policy(self, app, pg_conn):
+ agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
+ resp = self._get(app, pg_conn, agent_id, VIEWER)
+ assert _status(resp) == 200
+ data = _json(resp)
+ assert data["access"] == "viewer"
+ assert data["allowed_actions"] == ["pin", "use"]
+ assert data.get("key", "") == ""
+ assert data["config"] == {}
+
+ def test_stranger_404(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ assert _status(self._get(app, pg_conn, agent_id, STRANGER)) == 404
+
+
+class TestGetAgents:
+ def test_rows_carry_access(self, app, pg_conn):
+ from docsgpt.api.user.agents.routes import GetAgents
+
+ shared_id = _agent(pg_conn)
+ own = AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")
+ resp = _call(app, pg_conn, GetAgents, "get", "/api/get_agents", EDITOR)
+ rows = {r["id"]: r for r in _json(resp)}
+ assert rows[str(own["id"])]["access"] == "owner"
+ assert "delete" in rows[str(own["id"])]["allowed_actions"]
+ assert rows[shared_id]["access"] == "editor"
+ assert "edit" in rows[shared_id]["allowed_actions"]
+ assert rows[shared_id]["team_access"] == "editor"
+
+
+# ---------------------------------------------------------------------------
+# update_agent
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateAgent:
+ def _put(self, app, conn, agent_id, user, body):
+ from docsgpt.api.user.agents.routes import UpdateAgent
+
+ return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}",
+ user, json=body, args=(agent_id,))
+
+ def test_editor_edits_as_owner(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"name": "Renamed"})
+ assert _status(resp) == 200
+ row = _row(pg_conn, agent_id)
+ assert row["name"] == "Renamed"
+ assert row["user_id"] == OWNER
+
+ def test_viewer_403_stranger_404(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ assert _status(self._put(app, pg_conn, agent_id, VIEWER, {"name": "x"})) == 403
+ assert _status(self._put(app, pg_conn, agent_id, STRANGER, {"name": "x"})) == 404
+
+ def test_editor_policy_change_is_applied_not_dropped(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ body = {
+ "config": {"guardrails": {"enabled": True}},
+ "limited_request_mode": True,
+ "request_limit": 7,
+ }
+ assert _status(self._put(app, pg_conn, agent_id, EDITOR, body)) == 200
+ row = _row(pg_conn, agent_id)
+ assert row["config"]["guardrails"]["enabled"] is True
+ assert row["request_limit"] == 7
+
+ def test_editor_publish_mints_key_and_returns_it(self, app, pg_conn):
+ agent_id = _agent(pg_conn, status="draft", key="")
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
+ assert _status(resp) == 200
+ assert _json(resp).get("key")
+
+ def test_key_withheld_when_access_details_switch_off(self, app, pg_conn):
+ agent_id = _agent(pg_conn, status="draft", key="")
+ set_settings(pg_conn, "agent", agent_id,
+ {"editors_can_manage_access_details": False}, OWNER)
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
+ assert _status(resp) == 200
+ assert "key" not in _json(resp)
+ assert _row(pg_conn, agent_id)["key"]
+
+ def test_editor_cannot_move_folder(self, app, pg_conn):
+ from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
+
+ agent_id = _agent(pg_conn)
+ folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"folder_id": str(folder["id"])})
+ assert _status(resp) == 403
+
+ def test_editor_saving_unchanged_folder_is_fine(self, app, pg_conn):
+ from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
+
+ folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
+ agent_id = _agent(pg_conn, folder_id=str(folder["id"]))
+ resp = self._put(app, pg_conn, agent_id, EDITOR,
+ {"name": "n", "folder_id": str(folder["id"])})
+ assert _status(resp) == 200
+
+ def test_workflow_validated_against_owner(self, app, pg_conn):
+ from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
+ agent_id = _agent(pg_conn, agent_type="workflow")
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
+ assert _status(resp) == 200
+ assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
+
+ mine = WorkflowsRepository(pg_conn).create(EDITOR, "editor-wf")
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(mine["id"])})
+ assert _status(resp) == 404
+
+ def test_tool_gate(self, app, pg_conn):
+ from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+
+ tools = UserToolsRepository(pg_conn)
+ owner_tool = str(tools.create(OWNER, "api_tool")["id"])
+ foreign_tool = str(tools.create(STRANGER, "api_tool")["id"])
+ shared_tool = str(tools.create(STRANGER, "api_tool")["id"])
+ _team_share(pg_conn, "tool", shared_tool, owner=STRANGER)
+ agent_id = _agent(pg_conn)
+
+ ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
+ assert _status(ok) == 200
+ denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
+ assert _status(denied) == 403
+
+ set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
+ # Already attached: keeping it is fine.
+ assert _status(self._put(app, pg_conn, agent_id, EDITOR,
+ {"tools": [owner_tool, shared_tool]})) == 200
+
+
+class TestCreateAgentToolGate:
+ def test_foreign_tool_rejected(self, app, pg_conn):
+ from docsgpt.api.user.agents.routes import CreateAgent
+ from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+
+ foreign_tool = str(UserToolsRepository(pg_conn).create(STRANGER, "api_tool")["id"])
+ body = {"name": "n", "status": "draft", "tools": [foreign_tool]}
+ resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
+ assert _status(resp) == 403
+
+ def test_own_tool_accepted(self, app, pg_conn):
+ from docsgpt.api.user.agents.routes import CreateAgent
+ from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+
+ tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
+ body = {"name": "n", "status": "draft", "tools": [tool]}
+ resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
+ assert _status(resp) == 201
+
+
+# ---------------------------------------------------------------------------
+# access details: key, webhook, public link
+# ---------------------------------------------------------------------------
+
+
+class TestAccessDetails:
+ def _regen(self, app, conn, agent_id, user):
+ from docsgpt.api.user.agents.routes import RegenerateAgentKey
+
+ return _call(app, conn, RegenerateAgentKey, "post",
+ f"/api/regenerate_agent_key/{agent_id}", user, args=(agent_id,))
+
+ def test_regenerate_key_roles(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ assert _status(self._regen(app, pg_conn, agent_id, STRANGER)) == 404
+ assert _status(self._regen(app, pg_conn, agent_id, VIEWER)) == 403
+ resp = self._regen(app, pg_conn, agent_id, EDITOR)
+ assert _status(resp) == 200
+ assert _row(pg_conn, agent_id)["key"] == _json(resp)["key"]
+ set_settings(pg_conn, "agent", agent_id,
+ {"editors_can_manage_access_details": False}, OWNER)
+ assert _status(self._regen(app, pg_conn, agent_id, EDITOR)) == 403
+
+ def test_webhook_roles(self, app, pg_conn):
+ from docsgpt.api.user.agents.webhooks import AgentWebhook
+
+ agent_id = _agent(pg_conn)
+ path = f"/api/agent_webhook?id={agent_id}"
+ assert _status(_call(app, pg_conn, AgentWebhook, "get", path, VIEWER)) == 403
+ assert _status(_call(app, pg_conn, AgentWebhook, "get", path, STRANGER)) == 404
+ resp = _call(app, pg_conn, AgentWebhook, "get", path, EDITOR)
+ assert _status(resp) == 200
+ assert _row(pg_conn, agent_id)["incoming_webhook_token"]
+
+ def test_share_link_roles(self, app, pg_conn):
+ from docsgpt.api.user.agents.sharing import ShareAgent
+
+ agent_id = _agent(pg_conn)
+ body = {"id": agent_id, "shared": True}
+ assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
+ VIEWER, json=body)) == 403
+ assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
+ STRANGER, json=body)) == 404
+ resp = _call(app, pg_conn, ShareAgent, "put", "/api/share_agent", EDITOR, json=body)
+ assert _status(resp) == 200
+ assert _row(pg_conn, agent_id)["shared"] is True
+
+
+# ---------------------------------------------------------------------------
+# delete / export / folders
+# ---------------------------------------------------------------------------
+
+
+class TestDeleteAgent:
+ def _delete(self, app, conn, agent_id, user):
+ from docsgpt.api.user.agents.routes import DeleteAgent
+
+ return _call(app, conn, DeleteAgent, "delete", f"/api/delete_agent?id={agent_id}", user)
+
+ def test_editor_403_by_default(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 403
+ assert _status(self._delete(app, pg_conn, agent_id, STRANGER)) == 404
+ assert _row(pg_conn, agent_id) is not None
+
+ def test_editor_with_switch_deletes_and_settings_go(self, app, pg_conn):
+ from sqlalchemy import text
+
+ agent_id = _agent(pg_conn)
+ set_settings(pg_conn, "agent", agent_id, {"editors_can_delete": True}, OWNER)
+ assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 200
+ assert _row(pg_conn, agent_id) is None
+ assert settings_for(pg_conn, "agent", agent_id)["editors_can_delete"] is False
+ left = pg_conn.execute(
+ text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"),
+ {"id": agent_id},
+ ).scalar()
+ assert left == 0
+
+
+class TestExportAgent:
+ def test_roles(self, app, pg_conn):
+ from docsgpt.api.user.agents.portability import ExportAgent
+
+ agent_id = _agent(pg_conn)
+ path = f"/api/export_agent?id={agent_id}"
+ assert _status(_call(app, pg_conn, ExportAgent, "get", path, VIEWER)) == 403
+ assert _status(_call(app, pg_conn, ExportAgent, "get", path, STRANGER)) == 404
+ assert _status(_call(app, pg_conn, ExportAgent, "get", path, EDITOR)) == 200
+
+
+class TestFolderMoves:
+ def test_move_agent_owner_only(self, app, pg_conn):
+ from docsgpt.api.user.agents.folders import MoveAgentToFolder
+
+ agent_id = _agent(pg_conn)
+ body = {"agent_id": agent_id, "folder_id": None}
+ assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
+ "/api/agents/folders/move_agent", EDITOR, json=body)) == 403
+ assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
+ "/api/agents/folders/move_agent", STRANGER, json=body)) == 404
+ assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
+ "/api/agents/folders/move_agent", OWNER, json=body)) == 200
+
+ def test_bulk_move_reports_skipped(self, app, pg_conn):
+ from docsgpt.api.user.agents.folders import BulkMoveAgents
+ from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
+
+ shared_id = _agent(pg_conn)
+ own = str(AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")["id"])
+ folder = AgentFoldersRepository(pg_conn).create(EDITOR, "F")
+ body = {"agent_ids": [own, shared_id], "folder_id": str(folder["id"])}
+ resp = _call(app, pg_conn, BulkMoveAgents, "post", "/api/agents/folders/bulk_move",
+ EDITOR, json=body)
+ assert _status(resp) == 200
+ assert _json(resp)["moved"] == [own]
+ assert _json(resp)["skipped"] == [shared_id]
+ assert _row(pg_conn, shared_id)["folder_id"] is None
+
+
+# ---------------------------------------------------------------------------
+# pinned / link-shared lists
+# ---------------------------------------------------------------------------
+
+
+class TestListsCarryAccess:
+ def test_pinned_agents(self, app, pg_conn):
+ from docsgpt.api.user.agents.routes import PinAgent, PinnedAgents
+
+ agent_id = _agent(pg_conn)
+ assert _status(_call(app, pg_conn, PinAgent, "post",
+ f"/api/pin_agent?id={agent_id}", VIEWER)) == 200
+ rows = _json(_call(app, pg_conn, PinnedAgents, "get", "/api/pinned_agents", VIEWER))
+ assert rows[0]["access"] == "viewer"
+ assert rows[0]["allowed_actions"] == ["pin", "use"]
+
+ def test_link_shared_agent_is_viewer(self, app, pg_conn):
+ from docsgpt.api.user.agents.sharing import SharedAgent, SharedAgents
+
+ agent_id = _agent(pg_conn, share=False)
+ AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-r"})
+ resp = _call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-r", STRANGER)
+ data = _json(resp)
+ assert data["access"] == "viewer"
+ assert data["allowed_actions"] == ["pin", "use"]
+ assert "user" not in data
+ rows = _json(_call(app, pg_conn, SharedAgents, "get", "/api/shared_agents", STRANGER))
+ assert rows[0]["access"] == "viewer"
+ assert rows[0]["allowed_actions"] == ["pin", "use"]
+
+ def test_link_shared_agent_with_team_grant_uses_grant(self, app, pg_conn):
+ from docsgpt.api.user.agents.sharing import SharedAgent
+
+ agent_id = _agent(pg_conn)
+ AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-e"})
+ data = _json(_call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-e", EDITOR))
+ assert data["access"] == "editor"
+ assert "edit" in data["allowed_actions"]
+
+
+# ---------------------------------------------------------------------------
+# schedules
+# ---------------------------------------------------------------------------
+
+
+_RUN_NOW_TASK = type("T", (), {"apply_async": staticmethod(lambda **k: None)})
+
+
+class TestSchedules:
+ def _create(self, app, conn, agent_id, user):
+ from docsgpt.api.user.schedules.routes import AgentSchedules
+
+ body = {"instruction": "do it", "cron": "0 9 * * *"}
+ return _call(app, conn, AgentSchedules, "post", f"/api/agents/{agent_id}/schedules",
+ user, json=body, args=(agent_id,))
+
+ def test_editor_creates_as_owner(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ resp = self._create(app, pg_conn, agent_id, EDITOR)
+ assert _status(resp) == 201
+ assert _json(resp)["schedule"]["user_id"] == OWNER
+
+ def test_viewer_403_stranger_404(self, app, pg_conn):
+ from docsgpt.api.user.schedules.routes import AgentSchedules, AgentScheduleStats
+
+ agent_id = _agent(pg_conn)
+ assert _status(self._create(app, pg_conn, agent_id, VIEWER)) == 403
+ assert _status(self._create(app, pg_conn, agent_id, STRANGER)) == 404
+ path = f"/api/agents/{agent_id}/schedules"
+ assert _status(_call(app, pg_conn, AgentSchedules, "get", path, VIEWER,
+ args=(agent_id,))) == 403
+ assert _status(_call(app, pg_conn, AgentScheduleStats, "get", path + "/stats",
+ VIEWER, args=(agent_id,))) == 403
+
+ def test_editor_manages_owner_schedule(self, app, pg_conn):
+ from docsgpt.api.user.schedules.routes import (
+ AgentSchedules,
+ ScheduleResource,
+ ScheduleRunList,
+ ScheduleRunNow,
+ )
+
+ agent_id = _agent(pg_conn)
+ sid = _json(self._create(app, pg_conn, agent_id, OWNER))["schedule"]["id"]
+
+ listed = _call(app, pg_conn, AgentSchedules, "get", f"/api/agents/{agent_id}/schedules",
+ EDITOR, args=(agent_id,))
+ assert [s["id"] for s in _json(listed)["schedules"]] == [sid]
+
+ path = f"/api/schedules/{sid}"
+ assert _status(_call(app, pg_conn, ScheduleResource, "get", path, EDITOR,
+ args=(sid,))) == 200
+ assert _status(_call(app, pg_conn, ScheduleResource, "get", path, VIEWER,
+ args=(sid,))) == 403
+ assert _status(_call(app, pg_conn, ScheduleResource, "get", path, STRANGER,
+ args=(sid,))) == 404
+ put = _call(app, pg_conn, ScheduleResource, "put", path, EDITOR,
+ json={"name": "renamed"}, args=(sid,))
+ assert _json(put)["schedule"]["name"] == "renamed"
+ patched = _call(app, pg_conn, ScheduleResource, "patch", path, EDITOR,
+ json={"action": "pause"}, args=(sid,))
+ assert _json(patched)["schedule"]["status"] == "paused"
+
+ with patch("docsgpt.api.user.tasks.execute_scheduled_run", _RUN_NOW_TASK):
+ run = _call(app, pg_conn, ScheduleRunNow, "post", path + "/run", EDITOR, args=(sid,))
+ assert _status(run) == 202
+ assert _json(run)["run"]["user_id"] == OWNER
+ runs = _call(app, pg_conn, ScheduleRunList, "get", path + "/runs", EDITOR, args=(sid,))
+ assert len(_json(runs)["runs"]) == 1
+
+ assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, VIEWER,
+ args=(sid,))) == 403
+ assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, EDITOR,
+ args=(sid,))) == 200
+
+
+# ---------------------------------------------------------------------------
+# workflows
+# ---------------------------------------------------------------------------
+
+
+def _wf_body(name="WF"):
+ return {
+ "name": name,
+ "description": "d",
+ "nodes": [
+ {"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
+ {"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
+ ],
+ "edges": [{"id": "e1", "source": "start1", "target": "end1"}],
+ }
+
+
+class TestWorkflows:
+ def _setup(self, pg_conn):
+ from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+
+ wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
+ _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
+ return str(wf["id"])
+
+ def test_roles(self, app, pg_conn):
+ from docsgpt.api.user.workflows.routes import WorkflowDetail
+ from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+
+ wid = self._setup(pg_conn)
+ path = f"/api/workflows/{wid}"
+ assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, EDITOR, args=(wid,))) == 200
+ assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, VIEWER, args=(wid,))) == 403
+ assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, STRANGER, args=(wid,))) == 404
+
+ put = _call(app, pg_conn, WorkflowDetail, "put", path, EDITOR,
+ json=_wf_body("Edited"), args=(wid,))
+ assert _status(put) == 200
+ row = WorkflowsRepository(pg_conn).get_by_id(wid)
+ assert row["name"] == "Edited"
+ assert row["user_id"] == OWNER
+ assert _status(_call(app, pg_conn, WorkflowDetail, "put", path, VIEWER,
+ json=_wf_body(), args=(wid,))) == 403
+
+ assert _status(_call(app, pg_conn, WorkflowDetail, "delete", path, EDITOR,
+ args=(wid,))) == 403
+ assert WorkflowsRepository(pg_conn).get_by_id(wid) is not None
+
+
+# ---------------------------------------------------------------------------
+# analytics / logs / guardrail events
+# ---------------------------------------------------------------------------
+
+
+def _seed_conversation(conn, user_id, agent_id, count=2):
+ from docsgpt.storage.db.repositories.conversations import ConversationsRepository
+
+ repo = ConversationsRepository(conn)
+ conv = repo.create(user_id, name="t", agent_id=agent_id)
+ for i in range(count):
+ repo.append_message(str(conv["id"]), {"prompt": f"p{i}", "response": f"r{i}"})
+
+
+class TestAnalytics:
+ def _messages(self, app, conn, agent_id, user):
+ from docsgpt.api.user.analytics.routes import GetMessageAnalytics
+
+ return _call(app, conn, GetMessageAnalytics, "post", "/api/get_message_analytics",
+ user, json={"api_key_id": agent_id})
+
+ def test_editor_sees_owner_view(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ _seed_conversation(pg_conn, OWNER, agent_id, count=2)
+ _seed_conversation(pg_conn, "someone-else", agent_id, count=1)
+ resp = self._messages(app, pg_conn, agent_id, EDITOR)
+ assert _status(resp) == 200
+ assert sum(_json(resp)["messages"].values()) == 3
+
+ def test_viewer_403_unless_switch(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ _seed_conversation(pg_conn, OWNER, agent_id, count=2)
+ assert _status(self._messages(app, pg_conn, agent_id, VIEWER)) == 403
+ set_settings(pg_conn, "agent", agent_id, {"viewers_can_see_logs": True}, OWNER)
+ resp = self._messages(app, pg_conn, agent_id, VIEWER)
+ assert _status(resp) == 200
+ assert sum(_json(resp)["messages"].values()) == 2
+
+ def test_stranger_gets_empty(self, app, pg_conn):
+ agent_id = _agent(pg_conn)
+ _seed_conversation(pg_conn, OWNER, agent_id, count=2)
+ resp = self._messages(app, pg_conn, agent_id, STRANGER)
+ assert _status(resp) == 200
+ assert sum(_json(resp)["messages"].values()) == 0
+
+ @pytest.mark.parametrize(
+ "cls_name,path",
+ [
+ ("GetTokenAnalytics", "/api/get_token_analytics"),
+ ("GetFeedbackAnalytics", "/api/get_feedback_analytics"),
+ ("GetToolAnalytics", "/api/get_tool_analytics"),
+ ("GetScheduleAnalytics", "/api/get_schedule_analytics"),
+ ("GetUserLogs", "/api/get_user_logs"),
+ ],
+ )
+ def test_other_endpoints_gate_viewer(self, app, pg_conn, cls_name, path):
+ from docsgpt.api.user.analytics import routes
+
+ agent_id = _agent(pg_conn)
+ cls = getattr(routes, cls_name)
+ body = {"api_key_id": agent_id}
+ assert _status(_call(app, pg_conn, cls, "post", path, VIEWER, json=body)) == 403
+ assert _status(_call(app, pg_conn, cls, "post", path, EDITOR, json=body)) == 200
+
+ def test_logs_editor_sees_owner_chats(self, app, pg_conn):
+ from docsgpt.api.user.analytics.routes import GetUserLogs
+ from docsgpt.storage.db.repositories.user_logs import UserLogsRepository
+
+ agent_id = _agent(pg_conn)
+ UserLogsRepository(pg_conn).insert(
+ user_id=OWNER, endpoint="stream",
+ data={"agent_id": agent_id, "question": "q", "level": "info"},
+ )
+ resp = _call(app, pg_conn, GetUserLogs, "post", "/api/get_user_logs", EDITOR,
+ json={"api_key_id": agent_id, "event_type": "chat"})
+ assert _status(resp) == 200
+ assert len(_json(resp)["logs"]) == 1
+
+ def test_traces_gate(self, app, pg_conn):
+ from docsgpt.api.user.analytics.routes import GetTraces
+
+ agent_id = _agent(pg_conn)
+ path = f"/api/traces?request_id=r1&api_key_id={agent_id}"
+ assert _status(_call(app, pg_conn, GetTraces, "get", path, VIEWER)) == 403
+ assert _status(_call(app, pg_conn, GetTraces, "get", path, EDITOR)) == 200
+
+
+class TestGuardrailEvents:
+ def _seed(self, conn, agent_id):
+ from docsgpt.storage.db.repositories.guardrail_events import GuardrailEventsRepository
+
+ GuardrailEventsRepository(conn).record_many([
+ {
+ "user_id": OWNER, "agent_id": agent_id, "stage": "input",
+ "check_name": "pii", "detector_type": "regex", "action": "block",
+ "outcome": "triggered",
+ }
+ ])
+
+ def test_events_and_summary(self, app, pg_conn):
+ from docsgpt.api.user.agents.guardrails import GuardrailEvents, GuardrailSummary
+
+ agent_id = _agent(pg_conn)
+ self._seed(pg_conn, agent_id)
+ path = f"/api/guardrails/events?agent_id={agent_id}"
+ resp = _call(app, pg_conn, GuardrailEvents, "get", path, EDITOR)
+ assert _status(resp) == 200
+ assert len(_json(resp)["events"]) == 1
+ assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, VIEWER)) == 403
+ assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, STRANGER)) == 404
+
+ spath = f"/api/guardrails/summary?agent_id={agent_id}"
+ summary = _call(app, pg_conn, GuardrailSummary, "get", spath, EDITOR)
+ assert _status(summary) == 200
+ assert _json(summary)["totals"]["blocked"] == 1
+ assert _status(_call(app, pg_conn, GuardrailSummary, "get", spath, VIEWER)) == 403
diff --git a/tests/api/user/sources/test_chunks.py b/tests/api/user/sources/test_chunks.py
index 052e54b4..f5cd4999 100644
--- a/tests/api/user/sources/test_chunks.py
+++ b/tests/api/user/sources/test_chunks.py
@@ -30,15 +30,12 @@ def _seed_source(pg_conn, user="u", name="src"):
class TestResolveSource:
- def test_returns_none_for_missing(self, pg_conn):
+ def test_missing_raises_404(self, pg_conn):
+ from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
- with _patch_db(pg_conn):
- assert (
- _resolve_source(
- "00000000-0000-0000-0000-000000000000", "u"
- )
- is None
- )
+ with _patch_db(pg_conn), pytest.raises(AccessDenied) as exc:
+ _resolve_source("00000000-0000-0000-0000-000000000000", "u")
+ assert exc.value.status == 404
def test_returns_source_when_found(self, pg_conn):
from docsgpt.api.user.sources.chunks import _resolve_source
@@ -49,7 +46,8 @@ class TestResolveSource:
assert got is not None
assert str(got["id"]) == str(src["id"])
- def test_team_viewer_can_read(self, pg_conn):
+ def test_team_viewer_can_read_not_edit(self, pg_conn):
+ from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
@@ -65,10 +63,13 @@ class TestResolveSource:
)
with _patch_db(pg_conn):
got = _resolve_source(str(src["id"]), viewer)
- stranger = _resolve_source(str(src["id"]), "u-resolve-stranger")
- assert got is not None
+ with pytest.raises(AccessDenied) as edit:
+ _resolve_source(str(src["id"]), viewer, "edit")
+ with pytest.raises(AccessDenied) as stranger:
+ _resolve_source(str(src["id"]), "u-resolve-stranger")
assert str(got["id"]) == str(src["id"])
- assert stranger is None
+ assert edit.value.status == 403
+ assert stranger.value.status == 404
class TestChunkMatchesPath:
@@ -398,9 +399,9 @@ class TestAddChunk:
response = AddChunk().post()
assert response.status_code == 400
- def test_returns_403_inaccessible_source(self, app, pg_conn):
- # No ownership and no team editor grant resolves to None, which the
- # owner-or-editor gate answers as 403 "Source not accessible".
+ def test_returns_404_inaccessible_source(self, app, pg_conn):
+ # No ownership and no team grant: the source isn't visible → 404
+ # (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -413,7 +414,7 @@ class TestAddChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
- assert response.status_code == 403
+ assert response.status_code == 404
def test_adds_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import AddChunk
@@ -472,9 +473,9 @@ class TestDeleteChunk:
response = DeleteChunk().delete()
assert response.status_code == 401
- def test_returns_403_inaccessible_source(self, app, pg_conn):
- # No ownership and no team editor grant resolves to None, which the
- # owner-or-editor gate answers as 403 "Source not accessible".
+ def test_returns_404_inaccessible_source(self, app, pg_conn):
+ # No ownership and no team grant: the source isn't visible → 404
+ # (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -484,7 +485,7 @@ class TestDeleteChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
- assert response.status_code == 403
+ assert response.status_code == 404
def test_deletes_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import DeleteChunk
@@ -551,9 +552,9 @@ class TestUpdateChunk:
response = UpdateChunk().put()
assert response.status_code == 400
- def test_returns_403_inaccessible_source(self, app, pg_conn):
- # No ownership and no team editor grant resolves to None, which the
- # owner-or-editor gate answers as 403 "Source not accessible".
+ def test_returns_404_inaccessible_source(self, app, pg_conn):
+ # No ownership and no team grant: the source isn't visible → 404
+ # (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -566,7 +567,7 @@ class TestUpdateChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
- assert response.status_code == 403
+ assert response.status_code == 404
def test_returns_404_chunk_not_found(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import UpdateChunk
diff --git a/tests/api/user/sources/test_paginated_team_sharing.py b/tests/api/user/sources/test_paginated_team_sharing.py
index e6105bae..d2aee4ad 100644
--- a/tests/api/user/sources/test_paginated_team_sharing.py
+++ b/tests/api/user/sources/test_paginated_team_sharing.py
@@ -53,6 +53,8 @@ def _run(sub, repo, team_shared, client):
"docsgpt.api.user.sources.routes.visible_with_access",
return_value=team_shared,
),
+ # Owner switches all at their defaults.
+ patch("docsgpt.api.user.sources.routes.settings_many", return_value={}),
]
for p in patches:
p.start()
@@ -80,6 +82,9 @@ class TestPaginatedSourcesTeamSharing:
assert by_id[owned]["team_access"] is None
assert by_id[shared]["ownership"] == "team"
assert by_id[shared]["team_access"] == "editor"
+ assert by_id[owned]["access"] == "owner"
+ assert by_id[shared]["access"] == "editor"
+ assert "edit" in by_id[shared]["allowed_actions"]
# The shared ids are unioned into the owner-scoped queries by id.
assert repo.list_for_user.call_args.kwargs["extra_ids"] == [shared]
assert repo.count_for_user.call_args.kwargs["extra_ids"] == [shared]
diff --git a/tests/api/user/sources/test_routes.py b/tests/api/user/sources/test_routes.py
index 0fb117c8..ecfb386c 100644
--- a/tests/api/user/sources/test_routes.py
+++ b/tests/api/user/sources/test_routes.py
@@ -526,9 +526,9 @@ class TestSyncSource:
response = SyncSource().post()
assert response.status_code == 400
- def test_returns_403_inaccessible_source(self, app, pg_conn):
- # No ownership and no team editor grant resolves to None, which the
- # owner-or-editor gate answers as 403 "Source not accessible".
+ def test_returns_404_inaccessible_source(self, app, pg_conn):
+ # No ownership and no team grant: the source isn't visible → 404
+ # (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import SyncSource
with _patch_db(pg_conn), app.test_request_context(
@@ -539,7 +539,7 @@ class TestSyncSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = SyncSource().post()
- assert response.status_code == 403
+ assert response.status_code == 404
def test_returns_400_for_connector_type(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SyncSource
@@ -675,9 +675,9 @@ class TestReingestSource:
response = ReingestSource().post()
assert response.status_code == 400
- def test_returns_403_inaccessible_source(self, app, pg_conn):
- # No ownership and no team editor grant resolves to None, which the
- # owner-or-editor gate answers as 403 "Source not accessible".
+ def test_returns_404_inaccessible_source(self, app, pg_conn):
+ # No ownership and no team grant: the source isn't visible → 404
+ # (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import ReingestSource
with _patch_db(pg_conn), app.test_request_context(
@@ -688,7 +688,7 @@ class TestReingestSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = ReingestSource().post()
- assert response.status_code == 403
+ assert response.status_code == 404
def test_triggers_reingest_task(self, app, pg_conn):
from docsgpt.api.user.sources.routes import ReingestSource
diff --git a/tests/api/user/sources/test_source_roles.py b/tests/api/user/sources/test_source_roles.py
new file mode 100644
index 00000000..c4a2282a
--- /dev/null
+++ b/tests/api/user/sources/test_source_roles.py
@@ -0,0 +1,509 @@
+"""Owner / editor / viewer / stranger matrix for the source endpoints.
+
+Every source route resolves the caller's role through
+``docsgpt.api.user.resource_access``: 404 when the source isn't visible, 403
+when it is but the role can't perform the action, and writes land as the owner.
+"""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
+
+OWNER = "alice-roles"
+EDITOR = "bob-roles-editor"
+VIEWER = "carol-roles-viewer"
+STRANGER = "dave-roles-stranger"
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn, *modules):
+ @contextmanager
+ def _yield():
+ yield conn
+
+ patches = []
+ for mod in modules:
+ for name in ("db_session", "db_readonly"):
+ target = f"{mod}.{name}"
+ try:
+ p = patch(target, _yield)
+ p.start()
+ patches.append(p)
+ except AttributeError:
+ pass
+ try:
+ yield
+ finally:
+ for p in reversed(patches):
+ p.stop()
+
+
+ROUTES = "docsgpt.api.user.sources.routes"
+CHUNKS = "docsgpt.api.user.sources.chunks"
+UPLOAD = "docsgpt.api.user.sources.upload"
+CONNECTOR = "docsgpt.api.connector.routes"
+
+
+def _shared_source(pg_conn, **kwargs):
+ """Seed a source owned by OWNER, shared to EDITOR (editor) and VIEWER (viewer)."""
+ from docsgpt.storage.db.repositories.sources import SourcesRepository
+ from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+ from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+ )
+ from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+ src = SourcesRepository(pg_conn).create(
+ kwargs.pop("name", "shared-src"), user_id=OWNER, **kwargs
+ )
+ sid = str(src["id"])
+ for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
+ team = TeamsRepository(pg_conn).create(
+ f"T-{level}", f"t-{level}-{uuid.uuid4().hex[:8]}", OWNER
+ )
+ TeamMembersRepository(pg_conn).add_member(team["id"], member, role="team_member")
+ TeamResourceGrantsRepository(pg_conn).grant(
+ team["id"], "source", sid, owner_id=OWNER, granted_by=OWNER,
+ access_level=level,
+ )
+ return sid
+
+
+def _set(pg_conn, sid, **switches):
+ from docsgpt.api.user.resource_access import set_settings
+
+ set_settings(pg_conn, "source", sid, switches, OWNER)
+
+
+def _call(app, user, path, fn, method="GET", **ctx):
+ with app.test_request_context(path, method=method, **ctx):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ return fn()
+
+
+# ---------------------------------------------------------------------------
+# Listing
+# ---------------------------------------------------------------------------
+
+
+class TestListPayload:
+ def test_sources_rows_carry_access(self, app, pg_conn):
+ from docsgpt.api.user.sources.routes import CombinedJson
+
+ sid = _shared_source(pg_conn)
+ with _patch_db(pg_conn, ROUTES):
+ owner = _call(app, OWNER, "/api/sources", CombinedJson().get)
+ editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
+ viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
+ o = next(r for r in owner.json if r["id"] == sid)
+ e = next(r for r in editor.json if r["id"] == sid)
+ v = next(r for r in viewer.json if r["id"] == sid)
+ assert o["access"] == "owner"
+ assert "delete" in o["allowed_actions"]
+ assert o["ownership"] == "user"
+ assert e["access"] == "editor"
+ assert "edit" in e["allowed_actions"] and "delete" not in e["allowed_actions"]
+ assert e["allowed_actions"] == sorted(e["allowed_actions"])
+ assert e["team_access"] == "editor"
+ assert v["access"] == "viewer"
+ assert v["allowed_actions"] == ["use", "view_config"]
+ # viewers_can_see_config defaults on.
+ assert "config" in v
+
+ def test_viewer_config_hidden_when_switch_off(self, app, pg_conn):
+ from docsgpt.api.user.sources.routes import CombinedJson, PaginatedSources
+
+ sid = _shared_source(pg_conn)
+ _set(pg_conn, sid, viewers_can_see_config=False)
+ with _patch_db(pg_conn, ROUTES):
+ viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
+ editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
+ vpage = _call(app, VIEWER, "/api/sources/paginated", PaginatedSources().get)
+ v = next(r for r in viewer.json if r["id"] == sid)
+ e = next(r for r in editor.json if r["id"] == sid)
+ vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
+ assert "config" not in v
+ assert v["allowed_actions"] == ["use"]
+ assert "config" not in vp
+ assert "config" in e
+
+ def test_paginated_rows_carry_access(self, app, pg_conn):
+ from docsgpt.api.user.sources.routes import PaginatedSources
+
+ sid = _shared_source(pg_conn)
+ _set(pg_conn, sid, editors_can_delete=True)
+ with _patch_db(pg_conn, ROUTES):
+ owner = _call(app, OWNER, "/api/sources/paginated", PaginatedSources().get)
+ editor = _call(app, EDITOR, "/api/sources/paginated", PaginatedSources().get)
+ o = next(r for r in owner.json["paginated"] if r["id"] == sid)
+ e = next(r for r in editor.json["paginated"] if r["id"] == sid)
+ assert o["access"] == "owner"
+ assert e["access"] == "editor"
+ assert "delete" in e["allowed_actions"]
+
+
+# ---------------------------------------------------------------------------
+# Delete
+# ---------------------------------------------------------------------------
+
+
+def _delete(app, pg_conn, user, sid):
+ from docsgpt.api.user.sources.routes import DeleteOldIndexes
+
+ storage = MagicMock()
+ storage.is_directory.return_value = False
+ with _patch_db(pg_conn, ROUTES), patch(
+ f"{ROUTES}.settings.VECTOR_STORE", "milvus"
+ ), patch(f"{ROUTES}.StorageCreator.get_storage", return_value=storage), patch(
+ f"{ROUTES}.VectorCreator.create_vectorstore", return_value=MagicMock()
+ ):
+ return _call(app, user, f"/api/delete_old?source_id={sid}", DeleteOldIndexes().get)
+
+
+class TestDelete:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 403), (VIEWER, 403), (STRANGER, 404)])
+ def test_non_owner_denied_by_default(self, app, pg_conn, user, status):
+ from docsgpt.storage.db.repositories.sources import SourcesRepository
+
+ sid = _shared_source(pg_conn)
+ assert _delete(app, pg_conn, user, sid).status_code == status
+ assert SourcesRepository(pg_conn).get_by_id(sid) is not None
+
+ def test_editor_deletes_when_switch_on(self, app, pg_conn):
+ from sqlalchemy import text
+
+ from docsgpt.storage.db.repositories.sources import SourcesRepository
+
+ sid = _shared_source(pg_conn)
+ _set(pg_conn, sid, editors_can_delete=True)
+ assert _delete(app, pg_conn, EDITOR, sid).status_code == 200
+ assert SourcesRepository(pg_conn).get_by_id(sid) is None
+ grants = pg_conn.execute(
+ text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:i AS uuid)"),
+ {"i": sid},
+ ).scalar()
+ settings_rows = pg_conn.execute(
+ text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:i AS uuid)"),
+ {"i": sid},
+ ).scalar()
+ assert grants == 0
+ assert settings_rows == 0
+
+ def test_viewer_still_denied_when_switch_on(self, app, pg_conn):
+ sid = _shared_source(pg_conn)
+ _set(pg_conn, sid, editors_can_delete=True)
+ assert _delete(app, pg_conn, VIEWER, sid).status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# Sync frequency / sync / reingest / config
+# ---------------------------------------------------------------------------
+
+
+class TestManageSync:
+ @pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_matrix(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import ManageSync
+ from docsgpt.storage.db.repositories.sources import SourcesRepository
+
+ sid = _shared_source(pg_conn)
+ with _patch_db(pg_conn, ROUTES):
+ resp = _call(
+ app, user, "/api/manage_sync", ManageSync().post, method="POST",
+ json={"source_id": sid, "sync_frequency": "weekly"},
+ )
+ assert resp.status_code == status
+ got = SourcesRepository(pg_conn).get_by_id(sid)["sync_frequency"]
+ assert (got == "weekly") == (status == 200)
+
+
+class TestSyncSource:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_matrix_runs_as_owner(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import SyncSource
+
+ sid = _shared_source(
+ pg_conn, type="url", remote_data={"url": "https://example.com"}
+ )
+ delay = MagicMock(return_value=MagicMock(id="t-1"))
+ with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.sync_source.delay", delay):
+ resp = _call(
+ app, user, "/api/sync_source", SyncSource().post, method="POST",
+ json={"source_id": sid},
+ )
+ assert resp.status_code == status
+ if status == 200:
+ assert delay.call_args.kwargs["user"] == OWNER
+ else:
+ delay.assert_not_called()
+
+
+class TestReingest:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_matrix(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import ReingestSource
+
+ sid = _shared_source(pg_conn)
+ delay = MagicMock(return_value=MagicMock(id="t-2"))
+ with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reingest_source_task.delay", delay):
+ resp = _call(
+ app, user, "/api/sources/reingest", ReingestSource().post,
+ method="POST", json={"source_id": sid},
+ )
+ assert resp.status_code == status
+ if status == 200:
+ assert delay.call_args.kwargs["user"] == OWNER
+
+
+class TestConfig:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_matrix(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import SourceConfigResource
+
+ sid = _shared_source(pg_conn)
+ with _patch_db(pg_conn, ROUTES):
+ resp = _call(
+ app, user, f"/api/sources/{sid}/config",
+ lambda: SourceConfigResource().patch(sid), method="PATCH",
+ json={"retrieval": {"chunks": 7}},
+ )
+ assert resp.status_code == status
+
+
+# ---------------------------------------------------------------------------
+# Reads
+# ---------------------------------------------------------------------------
+
+
+class TestReads:
+ @pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 200), (STRANGER, 404)])
+ def test_directory_structure(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import DirectoryStructure
+
+ sid = _shared_source(pg_conn)
+ with _patch_db(pg_conn, ROUTES):
+ resp = _call(app, user, f"/api/directory_structure?id={sid}", DirectoryStructure().get)
+ assert resp.status_code == status
+
+ @pytest.mark.parametrize("user, status", [(VIEWER, 200), (STRANGER, 404)])
+ def test_get_chunks(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.chunks import GetChunks
+
+ sid = _shared_source(pg_conn)
+ store = MagicMock()
+ store.get_chunks.return_value = []
+ with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
+ resp = _call(app, user, f"/api/get_chunks?id={sid}", GetChunks().get)
+ assert resp.status_code == status
+
+
+# ---------------------------------------------------------------------------
+# Wiki writes
+# ---------------------------------------------------------------------------
+
+
+class TestWikiWrites:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_put_page(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import WikiPage
+
+ sid = _shared_source(pg_conn, type="wiki", config={"kind": "wiki"})
+ delay = MagicMock()
+ with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reembed_wiki_page.delay", delay):
+ resp = _call(
+ app, user, f"/api/sources/{sid}/wiki/page",
+ lambda: WikiPage().put(sid), method="PUT",
+ json={"path": "/a.md", "content": "hi"},
+ )
+ assert resp.status_code == status
+ if status == 200:
+ assert delay.call_args.kwargs["user"] == OWNER
+
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_convert(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.routes import ConvertSourceToWiki
+
+ sid = _shared_source(pg_conn)
+ with _patch_db(pg_conn, ROUTES):
+ resp = _call(
+ app, user, f"/api/sources/{sid}/wiki/convert",
+ lambda: ConvertSourceToWiki().post(sid), method="POST",
+ )
+ assert resp.status_code == status
+
+
+# ---------------------------------------------------------------------------
+# Chunks
+# ---------------------------------------------------------------------------
+
+
+class TestChunkWrites:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 201), (VIEWER, 403), (STRANGER, 404)])
+ def test_add(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.chunks import AddChunk
+
+ sid = _shared_source(pg_conn)
+ store = MagicMock()
+ store.add_chunk.return_value = "c1"
+ with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
+ resp = _call(
+ app, user, "/api/add_chunk", AddChunk().post, method="POST",
+ json={"id": sid, "text": "hello"},
+ )
+ assert resp.status_code == status
+ assert store.add_chunk.called == (status == 201)
+
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_delete(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.chunks import DeleteChunk
+
+ sid = _shared_source(pg_conn)
+ store = MagicMock()
+ store.delete_chunk.return_value = True
+ with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
+ resp = _call(
+ app, user, f"/api/delete_chunk?id={sid}&chunk_id=c1",
+ DeleteChunk().delete, method="DELETE",
+ )
+ assert resp.status_code == status
+
+ @pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
+ def test_update(self, app, pg_conn, user, status):
+ from docsgpt.api.user.sources.chunks import UpdateChunk
+
+ sid = _shared_source(pg_conn)
+ store = MagicMock()
+ store.get_chunks.return_value = [{"doc_id": "c1", "text": "a", "metadata": {}}]
+ store.update_chunk.return_value = "c1"
+ with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
+ resp = _call(
+ app, user, "/api/update_chunk", UpdateChunk().put, method="PUT",
+ json={"id": sid, "chunk_id": "c1", "text": "b"},
+ )
+ assert resp.status_code == status
+
+
+# ---------------------------------------------------------------------------
+# Files
+# ---------------------------------------------------------------------------
+
+
+class TestManageSourceFiles:
+ @pytest.mark.parametrize("user, status", [(EDITOR, 400), (VIEWER, 403), (STRANGER, 404)])
+ def test_matrix(self, app, pg_conn, user, status):
+ # An editor passes the role check and reaches the operation's own
+ # validation (no files → 400); viewer and stranger stop at the check.
+ from docsgpt.api.user.sources.upload import ManageSourceFiles
+
+ sid = _shared_source(pg_conn, file_path="/data/src")
+ with _patch_db(pg_conn, UPLOAD), patch(
+ f"{UPLOAD}.StorageCreator.get_storage", return_value=MagicMock()
+ ):
+ resp = _call(
+ app, user, "/api/manage_source_files", ManageSourceFiles().post,
+ method="POST", data={"source_id": sid, "operation": "add"},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == status
+
+
+# ---------------------------------------------------------------------------
+# Connector sync
+# ---------------------------------------------------------------------------
+
+
+def _connector_source(pg_conn):
+ return _shared_source(
+ pg_conn, name="drive-src", type="connector:file",
+ remote_data={"provider": "google_drive", "file_ids": ["f"], "folder_ids": []},
+ )
+
+
+def _owner_session(pg_conn, token="st-owner", token_info=None):
+ from docsgpt.storage.db.repositories.connector_sessions import (
+ ConnectorSessionsRepository,
+ )
+
+ repo = ConnectorSessionsRepository(pg_conn)
+ row = repo.upsert(OWNER, "google_drive", status="authorized")
+ repo.update(
+ str(row["id"]),
+ {
+ "session_token": token,
+ "token_info": token_info or {"access_token": "a", "refresh_token": "r"},
+ },
+ )
+
+
+def _connector_sync(app, pg_conn, user, body):
+ from docsgpt.api.connector.routes import ConnectorSync
+
+ delay = MagicMock(return_value=MagicMock(id="t-sync"))
+ with _patch_db(pg_conn, CONNECTOR), patch(
+ f"{CONNECTOR}.ingest_connector_task.delay", delay
+ ):
+ resp = _call(app, user, "/api/connectors/sync", ConnectorSync().post, method="POST", json=body)
+ return resp, delay
+
+
+class TestConnectorSync:
+ def test_editor_syncs_with_owner_session(self, app, pg_conn):
+ sid = _connector_source(pg_conn)
+ _owner_session(pg_conn)
+ resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
+ assert resp.status_code == 200
+ kwargs = delay.call_args.kwargs
+ assert kwargs["user"] == OWNER
+ assert kwargs["session_token"] == "st-owner"
+
+ def test_editor_cannot_substitute_own_session(self, app, pg_conn):
+ from docsgpt.storage.db.repositories.connector_sessions import (
+ ConnectorSessionsRepository,
+ )
+
+ sid = _connector_source(pg_conn)
+ _owner_session(pg_conn)
+ repo = ConnectorSessionsRepository(pg_conn)
+ row = repo.upsert(EDITOR, "google_drive", status="authorized")
+ repo.update(str(row["id"]), {"session_token": "st-editor", "token_info": {"access_token": "x"}})
+ resp, delay = _connector_sync(
+ app, pg_conn, EDITOR, {"source_id": sid, "session_token": "st-editor"}
+ )
+ assert resp.status_code == 200
+ assert delay.call_args.kwargs["session_token"] == "st-owner"
+
+ def test_owner_session_missing_returns_409(self, app, pg_conn):
+ sid = _connector_source(pg_conn)
+ resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
+ assert resp.status_code == 409
+ assert "owner" in resp.json["error"].lower()
+ delay.assert_not_called()
+
+ @pytest.mark.parametrize("user, status", [(VIEWER, 403), (STRANGER, 404)])
+ def test_viewer_and_stranger_denied(self, app, pg_conn, user, status):
+ sid = _connector_source(pg_conn)
+ _owner_session(pg_conn)
+ resp, delay = _connector_sync(app, pg_conn, user, {"source_id": sid})
+ assert resp.status_code == status
+ delay.assert_not_called()
+
+ def test_owner_still_uses_own_token(self, app, pg_conn):
+ sid = _connector_source(pg_conn)
+ _owner_session(pg_conn)
+ resp, delay = _connector_sync(
+ app, pg_conn, OWNER, {"source_id": sid, "session_token": "st-owner"}
+ )
+ assert resp.status_code == 200
+ assert delay.call_args.kwargs["user"] == OWNER
diff --git a/tests/api/user/teams/__init__.py b/tests/api/user/teams/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/api/user/teams/test_team_roles_routes.py b/tests/api/user/teams/test_team_roles_routes.py
new file mode 100644
index 00000000..e7245eea
--- /dev/null
+++ b/tests/api/user/teams/test_team_roles_routes.py
@@ -0,0 +1,531 @@
+"""Team sharing routes on the role model (``resource_access``), against real Postgres.
+
+Driven through the real app.py chokepoint; only ``handle_auth`` /
+``resolve_roles`` are patched, and the route modules' sessions are pointed at
+the per-test ``pg_conn``.
+"""
+
+from __future__ import annotations
+
+import json
+import uuid
+from contextlib import ExitStack, contextmanager
+from unittest.mock import patch
+
+import pytest
+
+from docsgpt.api.user.resource_access import set_settings, settings_for
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+from docsgpt.storage.db.repositories.users import UsersRepository
+
+
+@pytest.fixture
+def client():
+ from docsgpt.app import app as flask_app
+
+ flask_app.config["TESTING"] = True
+ return flask_app.test_client()
+
+
+@pytest.fixture
+def db(pg_conn):
+ @contextmanager
+ def _yield():
+ yield pg_conn
+
+ with ExitStack() as stack:
+ for target in (
+ "docsgpt.api.user.teams.routes.db_readonly",
+ "docsgpt.api.user.teams.routes.db_session",
+ "docsgpt.api.user.team_authz.db_readonly",
+ ):
+ stack.enter_context(patch(target, _yield))
+ stack.enter_context(patch("docsgpt.api.user.teams.routes.publish_user_event"))
+ yield pg_conn
+
+
+@contextmanager
+def _as(sub, *roles):
+ with patch("docsgpt.app.handle_auth", return_value={"sub": sub}), patch(
+ "docsgpt.app.resolve_roles", return_value=list(roles) or ["user"]
+ ):
+ yield
+
+
+def _body(resp):
+ return json.loads(resp.data)
+
+
+def _team(conn, owner="alice", members=(("bob", "team_member"),)):
+ team = TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
+ tid = str(team["id"])
+ repo = TeamMembersRepository(conn)
+ repo.add_member(tid, owner, role="team_admin")
+ for user, role in members:
+ repo.add_member(tid, user, role=role)
+ return tid
+
+
+def _agent(conn, owner="alice", name="Helper"):
+ return str(AgentsRepository(conn).create(owner, name, "published")["id"])
+
+
+def _grant(conn, tid, rid, level="viewer", rtype="agent", owner="alice", target=None):
+ TeamResourceGrantsRepository(conn).grant(
+ tid, rtype, rid, owner, owner, access_level=level, target_user_id=target
+ )
+
+
+def _grants(conn, tid):
+ return TeamResourceGrantsRepository(conn).list_for_team(tid)
+
+
+# --- POST /api/teams//grants -------------------------------------------
+
+
+class TestShareNeedsShareAction:
+ def test_editor_without_switch_gets_403(self, client, db):
+ tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ with _as("bob"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants",
+ json={"resource_type": "agent", "resource_id": aid, "target_user_id": "carol"},
+ )
+ assert resp.status_code == 403
+
+ def test_editor_with_switch_can_share_as_owner(self, client, db):
+ tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants",
+ json={
+ "resource_type": "agent",
+ "resource_id": aid,
+ "target_user_id": "carol",
+ "access_level": "viewer",
+ },
+ )
+ assert resp.status_code == 201
+ grant = _body(resp)["grant"]
+ assert grant["owner_id"] == "alice"
+ assert grant["granted_by"] == "bob"
+
+ def test_editor_with_switch_can_change_level(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants",
+ json={"resource_type": "agent", "resource_id": aid, "access_level": "viewer"},
+ )
+ assert resp.status_code == 201
+ assert _grants(db, tid)[0]["access_level"] == "viewer"
+
+ def test_viewer_gets_403(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants",
+ json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
+ )
+ assert resp.status_code == 403
+
+ def test_stranger_resource_is_404(self, client, db):
+ tid = _team(db)
+ aid = _agent(db, owner="mallory")
+ with _as("bob"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
+ )
+ assert resp.status_code == 404
+ assert _grants(db, tid) == []
+
+ def test_owner_shares(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants",
+ json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
+ )
+ assert resp.status_code == 201
+ assert _grants(db, tid)[0]["owner_id"] == "alice"
+
+ def test_caller_must_be_member(self, client, db):
+ tid = _team(db, owner="carol", members=())
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.post(
+ f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
+ )
+ assert resp.status_code == 403
+
+
+# --- DELETE /api/teams//grants -----------------------------------------
+
+
+class TestUnshare:
+ def test_owner_who_left_can_unshare(self, client, db):
+ tid = _team(db, owner="carol", members=(("alice", "team_member"),))
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ TeamMembersRepository(db).remove_member(tid, "alice")
+ with _as("alice"):
+ resp = client.delete(
+ f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
+ )
+ assert resp.status_code == 200
+ assert _body(resp)["success"] is True
+ assert _grants(db, tid) == []
+
+ def test_team_admin_unshares_someone_elses(self, client, db):
+ tid = _team(db, owner="carol", members=(("alice", "team_member"),))
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("carol"):
+ resp = client.delete(
+ f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
+ )
+ assert resp.status_code == 200
+ assert _grants(db, tid) == []
+
+ def test_member_without_share_gets_403(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ with _as("bob"):
+ resp = client.delete(
+ f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
+ )
+ assert resp.status_code == 403
+ assert len(_grants(db, tid)) == 1
+
+ def test_editor_with_switch_unshares_member_grant(self, client, db):
+ tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor", target="bob")
+ _grant(db, tid, aid, "viewer", target="carol")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.delete(
+ f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}&target_user_id=carol"
+ )
+ assert resp.status_code == 200
+ assert [g["target_user_id"] for g in _grants(db, tid)] == ["bob"]
+
+ def test_outsider_without_share_gets_403(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("mallory"):
+ resp = client.delete(
+ f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
+ )
+ assert resp.status_code == 403
+
+
+# --- GET /api/resource_shares -----------------------------------------------
+
+
+class TestResourceShares:
+ def test_viewer_gets_403(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("bob"):
+ resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 403
+
+ def test_editor_with_switch_lists(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 200
+ assert len(_body(resp)["shares"]) == 1
+
+ def test_owner_lists(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("alice"):
+ resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 200
+ assert _body(resp)["shares"][0]["team_name"] == "Acme"
+
+ def test_stranger_404(self, client, db):
+ aid = _agent(db)
+ with _as("mallory"):
+ resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 404
+
+
+# --- GET/PUT /api/resource_settings -----------------------------------------
+
+
+class TestResourceSettings:
+ def test_owner_get_shape(self, client, db):
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 200
+ body = _body(resp)
+ assert body["success"] is True
+ assert body["resource_type"] == "agent"
+ assert body["resource_id"] == aid
+ assert body["access"] == "owner"
+ assert "manage_settings" in body["allowed_actions"]
+ assert body["settings"][0] == {"key": "editors_can_share", "value": False, "default": False}
+
+ def test_viewer_can_read(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("bob"):
+ resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 200
+ assert _body(resp)["access"] == "viewer"
+ assert _body(resp)["allowed_actions"] == ["pin", "use"]
+
+ def test_stranger_404(self, client, db):
+ aid = _agent(db)
+ with _as("mallory"):
+ resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
+ assert resp.status_code == 404
+
+ def test_bad_type_400(self, client, db):
+ with _as("alice"):
+ resp = client.get(
+ f"/api/resource_settings?resource_type=nope&resource_id={uuid.uuid4()}"
+ )
+ assert resp.status_code == 400
+
+ def test_owner_put(self, client, db):
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={
+ "resource_type": "agent",
+ "resource_id": aid,
+ "settings": {"editors_can_share": True},
+ },
+ )
+ assert resp.status_code == 200
+ body = _body(resp)
+ assert body["settings"][0]["value"] is True
+ assert body["access"] == "owner"
+ assert settings_for(db, "agent", aid)["editors_can_share"] is True
+
+ def test_put_reflects_new_actions_for_prompt(self, client, db):
+ pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
+ with _as("alice"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={
+ "resource_type": "prompt",
+ "resource_id": pid,
+ "settings": {"viewers_can_duplicate": False},
+ },
+ )
+ assert resp.status_code == 200
+ assert {s["key"]: s["value"] for s in _body(resp)["settings"]}["viewers_can_duplicate"] is False
+
+ def test_put_unknown_key_400(self, client, db):
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={"resource_type": "agent", "resource_id": aid, "settings": {"nope": True}},
+ )
+ assert resp.status_code == 400
+
+ def test_put_non_bool_400(self, client, db):
+ aid = _agent(db)
+ with _as("alice"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={
+ "resource_type": "agent",
+ "resource_id": aid,
+ "settings": {"editors_can_share": "yes"},
+ },
+ )
+ assert resp.status_code == 400
+
+ def test_put_bad_type_400(self, client, db):
+ with _as("alice"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={"resource_type": "nope", "resource_id": str(uuid.uuid4()), "settings": {}},
+ )
+ assert resp.status_code == 400
+
+ def test_editor_put_403(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "editor")
+ set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
+ with _as("bob"):
+ resp = client.put(
+ "/api/resource_settings",
+ json={
+ "resource_type": "agent",
+ "resource_id": aid,
+ "settings": {"editors_can_delete": True},
+ },
+ )
+ assert resp.status_code == 403
+ assert settings_for(db, "agent", aid)["editors_can_delete"] is False
+
+
+# --- GET /api/teams//grants --------------------------------------------
+
+
+class TestGrantListing:
+ def test_rows_are_enriched(self, client, db):
+ UsersRepository(db).upsert("alice", email="alice@example.com")
+ UsersRepository(db).upsert("bob", email="bob@example.com")
+ tid = _team(db)
+ aid = _agent(db, name="Support bot")
+ tool = UserToolsRepository(db).create("alice", "api_tool", custom_name="CRM")
+ _grant(db, tid, aid, "editor", target="bob")
+ _grant(db, tid, str(tool["id"]), "viewer", rtype="tool")
+ with _as("bob"):
+ resp = client.get(f"/api/teams/{tid}/grants")
+ assert resp.status_code == 200
+ body = _body(resp)
+ assert body["team_role"] == "team_member"
+ by_type = {g["resource_type"]: g for g in body["grants"]}
+ agent_row = by_type["agent"]
+ assert agent_row["resource_name"] == "Support bot"
+ assert agent_row["owner_id"] == "alice"
+ assert agent_row["owner_label"] == "alice@example.com"
+ assert agent_row["target_user_label"] == "bob@example.com"
+ assert agent_row["granted_by_label"] == "alice@example.com"
+ assert agent_row["created_at"]
+ assert agent_row["access_level"] == "editor"
+ assert agent_row["caller"]["access"] == "editor"
+ assert "edit" in agent_row["caller"]["allowed_actions"]
+ tool_row = by_type["tool"]
+ assert tool_row["resource_name"] == "CRM"
+ assert tool_row["target_user_label"] is None
+ assert tool_row["caller"] == {
+ "access": "viewer",
+ "allowed_actions": ["use", "use_in_own"],
+ }
+
+ def test_owner_caller_and_missing_labels(self, client, db):
+ tid = _team(db)
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer")
+ with _as("alice"):
+ body = _body(client.get(f"/api/teams/{tid}/grants"))
+ row = body["grants"][0]
+ assert body["team_role"] == "team_admin"
+ assert row["caller"]["access"] == "owner"
+ assert row["owner_label"] is None
+
+ def test_member_does_not_see_others_personal_grants(self, client, db):
+ tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
+ a1, a2, a3 = _agent(db, name="Team"), _agent(db, name="Bob"), _agent(db, name="Carol")
+ _grant(db, tid, a1, "viewer")
+ _grant(db, tid, a2, "viewer", target="bob")
+ _grant(db, tid, a3, "viewer", target="carol")
+ with _as("bob"):
+ names = {g["resource_name"] for g in _body(client.get(f"/api/teams/{tid}/grants"))["grants"]}
+ assert names == {"Team", "Bob"}
+
+ def test_admin_and_sharer_see_all(self, client, db):
+ tid = _team(
+ db,
+ owner="carol",
+ members=(("alice", "team_member"), ("bob", "team_member"), ("dave", "team_member")),
+ )
+ aid = _agent(db)
+ _grant(db, tid, aid, "viewer", target="bob")
+ with _as("carol"): # team_admin
+ assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
+ with _as("alice"): # owner of the resource (has share)
+ assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
+ with _as("dave"):
+ assert _body(client.get(f"/api/teams/{tid}/grants"))["grants"] == []
+
+ def test_resource_type_filter_kept(self, client, db):
+ tid = _team(db)
+ _grant(db, tid, _agent(db), "viewer")
+ pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
+ _grant(db, tid, pid, "viewer", rtype="prompt")
+ with _as("bob"):
+ body = _body(client.get(f"/api/teams/{tid}/grants?resource_type=prompt"))
+ assert [g["resource_name"] for g in body["grants"]] == ["P"]
+
+
+# --- Team owner protection --------------------------------------------------
+
+
+class TestTeamOwnerProtection:
+ def test_admin_cannot_demote_owner(self, client, db):
+ tid = _team(db, members=(("bob", "team_admin"),))
+ with _as("bob"):
+ resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
+ assert resp.status_code == 403
+ assert TeamMembersRepository(db).role_for("alice", tid) == "team_admin"
+
+ def test_admin_cannot_remove_owner(self, client, db):
+ tid = _team(db, members=(("bob", "team_admin"),))
+ with _as("bob"):
+ resp = client.delete(f"/api/teams/{tid}/members/alice")
+ assert resp.status_code == 403
+ assert TeamMembersRepository(db).is_member("alice", tid)
+
+ def test_owner_must_transfer_before_leaving(self, client, db):
+ tid = _team(db, members=(("bob", "team_admin"),))
+ with _as("alice"):
+ resp = client.delete(f"/api/teams/{tid}/members/alice")
+ assert resp.status_code == 400
+ assert "transfer" in _body(resp)["message"].lower()
+ assert TeamMembersRepository(db).is_member("alice", tid)
+
+ def test_owner_cannot_self_demote(self, client, db):
+ tid = _team(db, members=(("bob", "team_admin"),))
+ with _as("alice"):
+ resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
+ assert resp.status_code == 400
+
+ def test_admin_can_still_manage_other_admins(self, client, db):
+ tid = _team(db, members=(("bob", "team_admin"), ("carol", "team_admin")))
+ with _as("bob"):
+ resp = client.put(f"/api/teams/{tid}/members/carol", json={"role": "team_member"})
+ assert resp.status_code == 200
+
+ def test_team_payloads_carry_is_owner(self, client, db):
+ tid = _team(db)
+ with _as("alice"):
+ listed = _body(client.get("/api/teams"))["teams"]
+ detail = _body(client.get(f"/api/teams/{tid}"))["team"]
+ assert listed[0]["is_owner"] is True
+ assert detail["is_owner"] is True
+ assert detail["owner_id"] == "alice"
+ with _as("bob"):
+ assert _body(client.get("/api/teams"))["teams"][0]["is_owner"] is False
+ assert _body(client.get(f"/api/teams/{tid}"))["team"]["is_owner"] is False
diff --git a/tests/api/user/test_prompts.py b/tests/api/user/test_prompts.py
index d0440a88..3e6b8df4 100644
--- a/tests/api/user/test_prompts.py
+++ b/tests/api/user/test_prompts.py
@@ -319,9 +319,9 @@ class TestGetSinglePromptHappyPath:
from docsgpt.api.user.prompts.routes import GetSinglePrompt
# Presets are composed in-process now, so the failure this covers is a
- # repository error on the custom-prompt path.
+ # database error on the custom-prompt path (the access check runs first).
with patch(
- "docsgpt.api.user.prompts.routes.PromptsRepository",
+ "docsgpt.api.user.prompts.routes.require",
side_effect=OSError("boom"),
), app.test_request_context("/api/get_single_prompt?id=some-custom-id"):
from flask import request
diff --git a/tests/api/user/test_prompts_access.py b/tests/api/user/test_prompts_access.py
new file mode 100644
index 00000000..cc6a8ced
--- /dev/null
+++ b/tests/api/user/test_prompts_access.py
@@ -0,0 +1,145 @@
+"""Roles on prompts: listing payload, get, update, delete."""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+from sqlalchemy import text
+
+from docsgpt.api.user.resource_access import set_settings
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+OWNER = "alice"
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with patch("docsgpt.api.user.prompts.routes.db_session", _yield), patch(
+ "docsgpt.api.user.prompts.routes.db_readonly", _yield
+ ):
+ yield
+
+
+def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
+ with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ return getattr(resource_cls(), method)()
+
+
+def _prompt(conn, name="P", content="C"):
+ return PromptsRepository(conn).create(OWNER, name, content)
+
+
+def _share(conn, prompt_id, member, level):
+ team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
+ TeamMembersRepository(conn).add_member(str(team["id"]), member)
+ TeamResourceGrantsRepository(conn).grant(
+ str(team["id"]), "prompt", str(prompt_id), OWNER, OWNER, access_level=level
+ )
+
+
+class TestGetPromptsAccess:
+ def _list(self, app, conn, user):
+ from docsgpt.api.user.prompts.routes import GetPrompts
+
+ resp = _call(app, conn, GetPrompts, user, method="get", path="/api/get_prompts")
+ assert resp.status_code == 200
+ return {p["id"]: p for p in resp.json}
+
+ def test_owner_and_grantee_payloads(self, app, pg_conn):
+ prompt = _prompt(pg_conn)
+ pid = str(prompt["id"])
+ _share(pg_conn, pid, "bob", "viewer")
+ own = self._list(app, pg_conn, OWNER)[pid]
+ assert own["access"] == "owner" and "delete" in own["allowed_actions"]
+ assert own["updated_at"]
+ shared = self._list(app, pg_conn, "bob")[pid]
+ assert shared["access"] == "viewer"
+ assert shared["allowed_actions"] == ["duplicate", "use"]
+ assert shared["type"] == "team" and shared["team_access"] == "viewer"
+ assert shared["updated_at"]
+
+ def test_presets_have_no_access_fields(self, app, pg_conn):
+ presets = self._list(app, pg_conn, OWNER)
+ assert "access" not in presets["default"]
+
+
+class TestGetSinglePromptAccess:
+ def test_by_role(self, app, pg_conn):
+ from docsgpt.api.user.prompts.routes import GetSinglePrompt
+
+ prompt = _prompt(pg_conn, content="Hello")
+ pid = str(prompt["id"])
+ _share(pg_conn, pid, "bob", "viewer")
+ for user, access in ((OWNER, "owner"), ("bob", "viewer")):
+ resp = _call(app, pg_conn, GetSinglePrompt, user, method="get", path=f"/api/get_single_prompt?id={pid}")
+ assert resp.status_code == 200
+ assert resp.json["content"] == "Hello"
+ assert resp.json["access"] == access
+ assert resp.json["updated_at"]
+ resp = _call(app, pg_conn, GetSinglePrompt, "eve", method="get", path=f"/api/get_single_prompt?id={pid}")
+ assert resp.status_code == 404
+
+
+class TestUpdatePromptAccess:
+ def test_by_role(self, app, pg_conn):
+ from docsgpt.api.user.prompts.routes import UpdatePrompt
+
+ prompt = _prompt(pg_conn)
+ pid = str(prompt["id"])
+ _share(pg_conn, pid, "ed", "editor")
+ _share(pg_conn, pid, "vi", "viewer")
+ body = {"id": pid, "name": "N", "content": "by editor"}
+ assert _call(app, pg_conn, UpdatePrompt, "ed", json=body).status_code == 200
+ assert PromptsRepository(pg_conn).get(pid, OWNER)["content"] == "by editor"
+ resp = _call(app, pg_conn, UpdatePrompt, "vi", json=body)
+ assert resp.status_code == 403 and resp.json["success"] is False
+ assert _call(app, pg_conn, UpdatePrompt, "eve", json=body).status_code == 404
+
+ def test_stale_write_is_409_for_owner_too(self, app, pg_conn):
+ from docsgpt.api.user.prompts.routes import UpdatePrompt
+
+ prompt = _prompt(pg_conn)
+ body = {"id": str(prompt["id"]), "name": "N", "content": "x",
+ "expected_updated_at": "2000-01-01T00:00:00+00:00"}
+ resp = _call(app, pg_conn, UpdatePrompt, OWNER, json=body)
+ assert resp.status_code == 409 and resp.json["code"] == "stale_write"
+
+
+class TestDeletePromptAccess:
+ def test_by_role_and_cleanup(self, app, pg_conn):
+ from docsgpt.api.user.prompts.routes import DeletePrompt
+
+ prompt = _prompt(pg_conn)
+ pid = str(prompt["id"])
+ _share(pg_conn, pid, "ed", "editor")
+ set_settings(pg_conn, "prompt", pid, {"editors_can_share": True}, OWNER)
+ assert _call(app, pg_conn, DeletePrompt, "ed", json={"id": pid}).status_code == 403
+ assert _call(app, pg_conn, DeletePrompt, "eve", json={"id": pid}).status_code == 404
+ assert _call(app, pg_conn, DeletePrompt, OWNER, json={"id": pid}).status_code == 200
+ assert PromptsRepository(pg_conn).get(pid, OWNER) is None
+ for table in ("team_resource_grants", "resource_share_settings"):
+ count = pg_conn.execute(
+ text(f"SELECT count(*) FROM {table} WHERE resource_id = CAST(:id AS uuid)"), {"id": pid}
+ ).scalar()
+ assert count == 0
diff --git a/tests/api/user/test_resource_access.py b/tests/api/user/test_resource_access.py
new file mode 100644
index 00000000..822f7f77
--- /dev/null
+++ b/tests/api/user/test_resource_access.py
@@ -0,0 +1,205 @@
+"""Tests for the single resource-access check (roles + per-asset switches)."""
+
+from __future__ import annotations
+
+import uuid
+
+import pytest
+
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ allowed_actions,
+ public_settings,
+ require,
+ resolve,
+ set_settings,
+ settings_for,
+ settings_many,
+)
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+
+def _team(conn, owner="alice"):
+ return TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
+
+
+def _share(conn, team, resource_type, resource_id, level, member="bob", owner="alice"):
+ TeamMembersRepository(conn).add_member(str(team["id"]), member)
+ TeamResourceGrantsRepository(conn).grant(
+ str(team["id"]), resource_type, resource_id, owner, owner, access_level=level
+ )
+
+
+def _agent(conn, owner="alice"):
+ return str(AgentsRepository(conn).create(owner, "A", "published")["id"])
+
+
+class TestAllowedActions:
+ def test_owner_gets_everything(self):
+ actions = allowed_actions("agent", "owner", {})
+ assert {"delete", "share", "manage_settings", "move_folder", "edit"} <= actions
+
+ def test_agent_editor_defaults(self):
+ actions = allowed_actions("agent", "editor", {})
+ assert {"use", "edit", "publish", "view_logs", "manage_schedules", "export",
+ "manage_access_details", "edit_policy"} <= actions
+ assert not {"delete", "share", "manage_settings", "move_folder"} & actions
+
+ def test_agent_viewer_defaults(self):
+ actions = allowed_actions("agent", "viewer", {})
+ assert actions == {"use", "pin"}
+
+ def test_switches_widen_and_narrow(self):
+ settings = {
+ "editors_can_share": True,
+ "editors_can_delete": True,
+ "editors_can_manage_access_details": False,
+ "viewers_can_see_logs": True,
+ }
+ editor = allowed_actions("agent", "editor", settings)
+ assert {"share", "delete"} <= editor
+ assert "manage_access_details" not in editor
+ assert "view_logs" in allowed_actions("agent", "viewer", settings)
+
+ def test_switches_never_touch_owner_only_settings(self):
+ # Even with every switch on, managing the switches stays owner-only.
+ settings = {"editors_can_share": True, "editors_can_delete": True}
+ assert "manage_settings" not in allowed_actions("agent", "editor", settings)
+
+ def test_tool_viewer_can_use_in_own_by_default(self):
+ assert "use_in_own" in allowed_actions("tool", "viewer", {})
+ assert "use_in_own" not in allowed_actions(
+ "tool", "viewer", {"viewers_can_use_in_agents": False}
+ )
+ assert "edit_credentials" in allowed_actions("tool", "editor", {})
+ assert "edit_credentials" not in allowed_actions(
+ "tool", "editor", {"editors_can_change_credentials": False}
+ )
+
+ def test_source_and_prompt_defaults(self):
+ assert "view_config" in allowed_actions("source", "viewer", {})
+ assert "edit" not in allowed_actions("source", "viewer", {})
+ assert "edit" in allowed_actions("source", "editor", {})
+ assert "reconnect" not in allowed_actions("source", "editor", {})
+ assert "duplicate" in allowed_actions("prompt", "viewer", {})
+ assert "edit" in allowed_actions("prompt", "editor", {})
+ assert "edit" not in allowed_actions("prompt", "viewer", {})
+
+ def test_no_access_means_nothing(self):
+ assert allowed_actions("agent", None, {}) == set()
+
+
+class TestSettings:
+ def test_defaults_when_no_row(self, pg_conn):
+ aid = _agent(pg_conn)
+ assert settings_for(pg_conn, "agent", aid) == {
+ "editors_can_share": False,
+ "editors_can_delete": False,
+ "editors_can_manage_access_details": True,
+ "viewers_can_see_logs": False,
+ }
+
+ def test_set_and_read_back(self, pg_conn):
+ aid = _agent(pg_conn)
+ set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
+ assert settings_for(pg_conn, "agent", aid)["viewers_can_see_logs"] is True
+ # A partial update keeps earlier values.
+ set_settings(pg_conn, "agent", aid, {"editors_can_share": True}, "alice")
+ merged = settings_for(pg_conn, "agent", aid)
+ assert merged["viewers_can_see_logs"] is True
+ assert merged["editors_can_share"] is True
+
+ def test_unknown_key_rejected(self, pg_conn):
+ aid = _agent(pg_conn)
+ with pytest.raises(ValueError):
+ set_settings(pg_conn, "agent", aid, {"viewers_can_delete": True}, "alice")
+ with pytest.raises(ValueError):
+ set_settings(pg_conn, "agent", aid, {"editors_can_share": "yes"}, "alice")
+
+ def test_settings_many(self, pg_conn):
+ a1, a2 = _agent(pg_conn), _agent(pg_conn)
+ set_settings(pg_conn, "agent", a1, {"editors_can_delete": True}, "alice")
+ many = settings_many(pg_conn, "agent", [a1, a2, "not-a-uuid"])
+ assert many[a1]["editors_can_delete"] is True
+ assert many[a2]["editors_can_delete"] is False
+ assert many["not-a-uuid"]["editors_can_delete"] is False
+
+ def test_public_settings_shape(self):
+ rows = public_settings("prompt", {"editors_can_share": True})
+ assert [r["key"] for r in rows] == ["editors_can_share", "viewers_can_duplicate"]
+ assert rows[0] == {"key": "editors_can_share", "value": True, "default": False}
+
+
+class TestResolve:
+ def test_owner(self, pg_conn):
+ aid = _agent(pg_conn)
+ ra = resolve(pg_conn, "agent", aid, "alice")
+ assert ra.access == "owner"
+ assert ra.owner_id == "alice"
+ assert ra.can("delete")
+
+ def test_editor_and_viewer_via_team(self, pg_conn):
+ team = _team(pg_conn)
+ aid = _agent(pg_conn)
+ _share(pg_conn, team, "agent", aid, "editor", member="bob")
+ ra = resolve(pg_conn, "agent", aid, "bob")
+ assert ra.access == "editor"
+ assert ra.owner_id == "alice"
+ assert ra.can("view_logs") and not ra.can("delete")
+
+ aid2 = _agent(pg_conn)
+ _share(pg_conn, team, "agent", aid2, "viewer", member="carol")
+ rv = resolve(pg_conn, "agent", aid2, "carol")
+ assert rv.access == "viewer"
+ assert rv.actions == frozenset({"use", "pin"})
+
+ def test_switch_applies_to_grantee(self, pg_conn):
+ team = _team(pg_conn)
+ aid = _agent(pg_conn)
+ _share(pg_conn, team, "agent", aid, "viewer")
+ set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
+ assert resolve(pg_conn, "agent", aid, "bob").can("view_logs")
+
+ def test_stranger_and_bad_ids(self, pg_conn):
+ aid = _agent(pg_conn)
+ assert resolve(pg_conn, "agent", aid, "mallory") is None
+ assert resolve(pg_conn, "agent", "not-a-uuid", "alice") is None
+ assert resolve(pg_conn, "agent", str(uuid.uuid4()), "alice") is None
+ assert resolve(pg_conn, "nope", aid, "alice") is None
+
+ def test_payload(self, pg_conn):
+ aid = _agent(pg_conn)
+ payload = resolve(pg_conn, "agent", aid, "alice").payload()
+ assert payload["access"] == "owner"
+ assert "delete" in payload["allowed_actions"]
+ assert payload["allowed_actions"] == sorted(payload["allowed_actions"])
+
+
+class TestRequire:
+ def test_allowed_returns_access(self, pg_conn):
+ aid = _agent(pg_conn)
+ assert require(pg_conn, "agent", aid, "alice", "delete").owner_id == "alice"
+
+ def test_visible_but_not_allowed_is_403(self, pg_conn):
+ team = _team(pg_conn)
+ aid = _agent(pg_conn)
+ _share(pg_conn, team, "agent", aid, "viewer")
+ with pytest.raises(AccessDenied) as exc:
+ require(pg_conn, "agent", aid, "bob", "edit")
+ assert exc.value.status == 403
+
+ def test_invisible_is_404(self, pg_conn):
+ aid = _agent(pg_conn)
+ with pytest.raises(AccessDenied) as exc:
+ require(pg_conn, "agent", aid, "mallory", "use")
+ assert exc.value.status == 404
+
+ def test_unknown_action_is_a_bug(self, pg_conn):
+ aid = _agent(pg_conn)
+ with pytest.raises(KeyError):
+ require(pg_conn, "agent", aid, "alice", "fly")
diff --git a/tests/api/user/test_scheduler_worker.py b/tests/api/user/test_scheduler_worker.py
index 09528cd5..71afc851 100644
--- a/tests/api/user/test_scheduler_worker.py
+++ b/tests/api/user/test_scheduler_worker.py
@@ -616,3 +616,67 @@ class TestExecuteScheduledRunBody:
meta = messages[0]._mapping["message_metadata"]
assert meta.get("scheduled") is True
assert "schedule.message.appended" in {e[0] for e in stub_events}
+
+
+class TestRunAsOwnerAccessRecheck:
+ """A schedule someone set on another user's agent re-checks their access
+ on every run; the run itself always executes as the agent's owner."""
+
+ def _member_run(self, conn, *, grant: bool):
+ import uuid as _uuid
+
+ from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+ from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+ )
+ from docsgpt.storage.db.repositories.teams import TeamsRepository
+
+ agent_id = _make_agent(conn, "owner-x")
+ if grant:
+ team = TeamsRepository(conn).create("T", f"t-{_uuid.uuid4().hex[:8]}", "owner-x")
+ TeamMembersRepository(conn).add_member(str(team["id"]), "member-y")
+ TeamResourceGrantsRepository(conn).grant(
+ str(team["id"]), "agent", agent_id, "owner-x", "owner-x",
+ access_level="viewer",
+ )
+ schedule = SchedulesRepository(conn).create(
+ user_id="member-y", agent_id=agent_id, trigger_type="recurring",
+ instruction="hello", cron="* * * * *",
+ next_run_at=_now() + timedelta(minutes=5),
+ )
+ run = ScheduleRunsRepository(conn).record_pending(
+ str(schedule["id"]), "member-y", agent_id, _now(),
+ )
+ return run
+
+ def test_revoked_member_run_fails_without_running(
+ self, pg_engine, patched_engine, stub_events,
+ ):
+ with pg_engine.begin() as conn:
+ run = self._member_run(conn, grant=False)
+ with patch(
+ "docsgpt.api.user.scheduler_worker.run_agent_headless",
+ ) as headless:
+ result = execute_scheduled_run_body(str(run["id"]), "celery-r")
+ headless.assert_not_called()
+ assert result["status"] == "failed"
+ with pg_engine.connect() as conn:
+ row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
+ assert row["status"] == "failed"
+ assert row["error"] == "agent access revoked"
+
+ def test_member_with_grant_runs(self, pg_engine, patched_engine, stub_events):
+ with pg_engine.begin() as conn:
+ run = self._member_run(conn, grant=True)
+ with patch(
+ "docsgpt.api.user.scheduler_worker.run_agent_headless",
+ return_value={
+ "answer": "ok", "tool_calls": [], "sources": [], "thought": "",
+ "prompt_tokens": 1, "generated_tokens": 1, "denied": [],
+ "error_type": None, "model_id": "m",
+ },
+ ) as headless:
+ result = execute_scheduled_run_body(str(run["id"]), "celery-g")
+ assert result["status"] == "success"
+ # Runs with the owner's agent row (owner's context).
+ assert headless.call_args.args[0]["user_id"] == "owner-x"
diff --git a/tests/api/user/test_schedules_routes.py b/tests/api/user/test_schedules_routes.py
index 7baf5256..6d8f99cc 100644
--- a/tests/api/user/test_schedules_routes.py
+++ b/tests/api/user/test_schedules_routes.py
@@ -493,7 +493,7 @@ class TestUnexpectedExceptionMasked:
raise RuntimeError("internal detail: secret connection string")
with _patch_db(pg_conn), patch.object(
- SchedulesRepository, "get", side_effect=_boom,
+ SchedulesRepository, "get_internal", side_effect=_boom,
), app.test_request_context(
f"/api/schedules/{s['id']}", method="GET",
):
diff --git a/tests/api/user/test_tools_access.py b/tests/api/user/test_tools_access.py
new file mode 100644
index 00000000..07d6f3c3
--- /dev/null
+++ b/tests/api/user/test_tools_access.py
@@ -0,0 +1,571 @@
+"""Roles on tools: listing payload, write actions, secrets, chat preferences, MCP save."""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
+
+from docsgpt.api.user.resource_access import set_settings
+from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+from docsgpt.storage.db.repositories.user_tool_preferences import (
+ UserToolPreferencesRepository,
+)
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+from docsgpt.storage.db.repositories.users import UsersRepository
+
+OWNER = "alice"
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with patch("docsgpt.api.user.tools.routes.db_session", _yield), patch(
+ "docsgpt.api.user.tools.routes.db_readonly", _yield
+ ), patch("docsgpt.api.user.tools.mcp.db_session", _yield), patch(
+ "docsgpt.api.user.tools.mcp.db_readonly", _yield
+ ), patch("docsgpt.api.user.tools.routes.validate_url"), patch(
+ "docsgpt.api.user.tools.mcp.validate_url"
+ ):
+ # URL validation resolves DNS; the SSRF gate has its own tests.
+ yield
+
+
+def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
+ with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ return getattr(resource_cls(), method)()
+
+
+def _share(conn, tool_id, member, level, *, team_name="Acme"):
+ team = TeamsRepository(conn).create(team_name, f"t-{uuid.uuid4().hex[:8]}", OWNER)
+ TeamMembersRepository(conn).add_member(str(team["id"]), member)
+ TeamResourceGrantsRepository(conn).grant(
+ str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level
+ )
+ return team
+
+
+def _tool(conn, name="read_webpage", config=None, status=True, owner=OWNER):
+ return UserToolsRepository(conn).create(
+ owner, name, config=config or {}, display_name=name, description="",
+ actions=[{"name": "act", "active": True, "require_approval": False}], status=status,
+ )
+
+
+def _api_config(url="https://api.example.com/users", header_value="sk-secret", query_value="q-secret"):
+ return {
+ "actions": {
+ "get_users": {
+ "name": "get_users",
+ "description": "Get users",
+ "url": url,
+ "method": "GET",
+ "active": True,
+ "headers": {"type": "object", "properties": {
+ "X-Key": {"type": "string", "description": "k", "value": header_value,
+ "filled_by_llm": False},
+ }},
+ "query_params": {"type": "object", "properties": {
+ "token": {"type": "string", "description": "t", "value": query_value,
+ "filled_by_llm": False},
+ "limit": {"type": "integer", "description": "l", "value": "", "filled_by_llm": True},
+ }},
+ "body": {"type": "object", "properties": {}},
+ }
+ }
+ }
+
+
+def _row(conn, tool_id, owner=OWNER):
+ return UserToolsRepository(conn).get(str(tool_id), owner)
+
+
+def _runtime_action(conn, tool_id, owner=OWNER):
+ from docsgpt.agents.tool_executor import api_tool_action_with_secrets
+
+ row = _row(conn, tool_id, owner)
+ return api_tool_action_with_secrets(row, "get_users", owner)
+
+
+# ---------------------------------------------------------------------------
+# 1. GET /api/get_tools
+# ---------------------------------------------------------------------------
+class TestGetToolsAccess:
+ def _tools(self, app, conn, user):
+ from docsgpt.api.user.tools.routes import GetTools
+
+ resp = _call(app, conn, GetTools, user, method="get", path="/api/get_tools")
+ assert resp.status_code == 200
+ return {t["id"]: t for t in resp.json["tools"] if not t.get("default") and not t.get("builtin")}
+
+ def test_owner_row_carries_owner_payload_and_in_chat_from_status(self, app, pg_conn):
+ tool = _tool(pg_conn, status=False)
+ row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
+ assert row["access"] == "owner"
+ assert {"delete", "share", "manage_settings", "edit"} <= set(row["allowed_actions"])
+ assert row["allowed_actions"] == sorted(row["allowed_actions"])
+ assert row["in_chat"] is False
+ assert row["ownership"] == "user"
+
+ def test_viewer_row_payload_shared_via_owner_label_and_preference(self, app, pg_conn):
+ UsersRepository(pg_conn).upsert(OWNER, email="alice@example.com")
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "bob", "viewer", team_name="Research")
+ row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
+ assert row["access"] == "viewer"
+ assert row["allowed_actions"] == ["use", "use_in_own"]
+ assert row["in_chat"] is False # sharing never adds to chats
+ assert row["shared_via"] == "Research"
+ assert row["owner_label"] == "alice@example.com"
+ assert row["ownership"] == "team" and row["team_access"] == "viewer"
+
+ UserToolPreferencesRepository(pg_conn).set_in_chat("bob", str(tool["id"]), True)
+ assert self._tools(app, pg_conn, "bob")[str(tool["id"])]["in_chat"] is True
+
+ def test_owner_label_null_without_user_row(self, app, pg_conn):
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "bob", "editor")
+ row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
+ assert row["owner_label"] is None
+ assert "edit" in row["allowed_actions"] and "delete" not in row["allowed_actions"]
+
+ def test_default_rows_carry_in_chat(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import GetTools
+
+ resp = _call(app, pg_conn, GetTools, OWNER, method="get", path="/api/get_tools")
+ defaults = [t for t in resp.json["tools"] if t.get("default")]
+ assert all(t["in_chat"] == t["status"] for t in defaults)
+
+ def test_api_tool_secret_values_masked_for_owner_and_grantee(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
+
+ sealed = _seal_api_tool_secrets(_api_config(), {}, OWNER)
+ tool = _tool(pg_conn, name="api_tool", config=sealed)
+ _share(pg_conn, tool["id"], "bob", "viewer")
+ for user in (OWNER, "bob"):
+ row = self._tools(app, pg_conn, user)[str(tool["id"])]
+ action = row["config"]["actions"]["get_users"]
+ header = action["headers"]["properties"]["X-Key"]
+ assert header["value"] == "" and header["has_value"] is True
+ token = action["query_params"]["properties"]["token"]
+ assert token["value"] == "" and token["has_value"] is True
+ limit = action["query_params"]["properties"]["limit"]
+ assert limit["value"] == "" and limit.get("has_value") is False
+ assert "encrypted_action_secrets" not in row["config"]
+ assert "encrypted_credentials" not in row["config"]
+
+ def test_legacy_plaintext_api_tool_values_masked(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config=_api_config())
+ row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
+ header = row["config"]["actions"]["get_users"]["headers"]["properties"]["X-Key"]
+ assert header == {**header, "value": "", "has_value": True}
+
+
+# ---------------------------------------------------------------------------
+# 2. api_tool secret storage
+# ---------------------------------------------------------------------------
+class TestApiToolSecrets:
+ def _save(self, app, conn, user, tool_id, config):
+ from docsgpt.api.user.tools.routes import UpdateTool
+
+ return _call(app, conn, UpdateTool, user, json={"id": str(tool_id), "config": config})
+
+ def _masked(self, config):
+ import copy
+
+ out = copy.deepcopy(config)
+ for section in ("headers", "query_params"):
+ for spec in out["actions"]["get_users"][section]["properties"].values():
+ spec["has_value"] = bool(spec.get("value"))
+ spec["value"] = ""
+ return out
+
+ def test_values_stored_encrypted_not_plaintext(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ assert self._save(app, pg_conn, OWNER, tool["id"], _api_config()).status_code == 200
+ stored = _row(pg_conn, tool["id"])["config"]
+ header = stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]
+ assert header["value"] == ""
+ assert stored["encrypted_action_secrets"]
+ blob = decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
+ assert blob["get_users"]["headers"]["X-Key"] == "sk-secret"
+ action = _runtime_action(pg_conn, tool["id"])
+ assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+ assert action["query_params"]["properties"]["token"]["value"] == "q-secret"
+
+ def test_empty_value_with_has_value_keeps_stored_secret(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ masked = self._masked(_api_config())
+ masked["actions"]["get_users"]["description"] = "Renamed"
+ assert self._save(app, pg_conn, OWNER, tool["id"], masked).status_code == 200
+ action = _runtime_action(pg_conn, tool["id"])
+ assert action["description"] == "Renamed"
+ assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+
+ def test_new_value_replaces_and_cleared_has_value_drops(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ cfg = self._masked(_api_config())
+ cfg["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] = "sk-new"
+ cfg["actions"]["get_users"]["query_params"]["properties"]["token"]["has_value"] = False
+ self._save(app, pg_conn, OWNER, tool["id"], cfg)
+ action = _runtime_action(pg_conn, tool["id"])
+ assert action["headers"]["properties"]["X-Key"]["value"] == "sk-new"
+ assert action["query_params"]["properties"]["token"]["value"] == ""
+
+ def test_legacy_plaintext_reencrypted_on_save(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config=_api_config())
+ # Runtime reads the legacy plaintext value transparently.
+ assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+ self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config()))
+ stored = _row(pg_conn, tool["id"])["config"]
+ assert stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] == ""
+ assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+
+ def test_editor_save_encrypts_with_owner_key(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ _share(pg_conn, tool["id"], "bob", "editor")
+ assert self._save(app, pg_conn, "bob", tool["id"], _api_config()).status_code == 200
+ stored = _row(pg_conn, tool["id"])["config"]
+ assert decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
+ assert UserToolsRepository(pg_conn).list_for_user("bob") == []
+
+ def test_url_host_change_clears_secrets_and_requires_new_ones(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ moved = self._masked(_api_config(url="https://evil.example.org/users"))
+ resp = self._save(app, pg_conn, OWNER, tool["id"], moved)
+ assert resp.status_code == 400
+ assert "credentials" in resp.json["message"].lower()
+ # Same host, different path: stored values kept.
+ same_host = self._masked(_api_config(url="https://api.example.com/v2/users"))
+ assert self._save(app, pg_conn, OWNER, tool["id"], same_host).status_code == 200
+ assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+
+ def test_url_host_change_with_new_secrets_succeeds(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ moved = _api_config(url="https://other.example.org/users", header_value="sk-2", query_value="q-2")
+ assert self._save(app, pg_conn, OWNER, tool["id"], moved).status_code == 200
+ action = _runtime_action(pg_conn, tool["id"])
+ assert action["headers"]["properties"]["X-Key"]["value"] == "sk-2"
+
+
+# ---------------------------------------------------------------------------
+# 3. Write actions
+# ---------------------------------------------------------------------------
+class TestToolWrites:
+ def test_update_tool_rename_by_role(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateTool
+
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "ed", "editor")
+ _share(pg_conn, tool["id"], "vi", "viewer")
+ body = {"id": str(tool["id"]), "customName": "Renamed"}
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
+ resp = _call(app, pg_conn, UpdateTool, "vi", json=body)
+ assert resp.status_code == 403 and resp.json["success"] is False
+ assert _call(app, pg_conn, UpdateTool, "stranger", json=body).status_code == 404
+
+ def test_update_tool_config_needs_edit_credentials(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateTool, UpdateToolConfig
+
+ tool = _tool(pg_conn, name="brave", config={})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
+ body = {"id": str(tool["id"]), "config": {"token": "x"}}
+ assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
+ set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": True}, OWNER)
+ assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
+
+ def test_update_tool_config_encrypts_with_owner_key(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ tool = _tool(pg_conn, name="mcp_tool", config={"server_url": "https://mcp.example.com/x",
+ "auth_type": "bearer"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/x",
+ "auth_type": "bearer", "bearer_token": "tok"}}
+ assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
+ stored = _row(pg_conn, tool["id"])["config"]
+ assert decrypt_credentials(stored["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
+
+ def test_update_tool_config_host_change_clears_stored_secrets(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
+ "encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
+ tool = _tool(pg_conn, name="mcp_tool", config=cfg)
+ body = {"id": str(tool["id"]), "config": {"server_url": "https://other.example.org/x",
+ "auth_type": "bearer"}}
+ resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
+ assert resp.status_code == 400
+ assert "credentials" in resp.json["message"].lower()
+ stored = _row(pg_conn, tool["id"])["config"]
+ assert stored["server_url"] == "https://mcp.example.com/x"
+
+ def test_api_tool_description_edit_needs_only_edit(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateTool, _seal_api_tool_secrets
+
+ tool = _tool(pg_conn, name="api_tool", config=_seal_api_tool_secrets(_api_config(), {}, OWNER))
+ _share(pg_conn, tool["id"], "ed", "editor")
+ set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
+ cfg = TestApiToolSecrets()._masked(_api_config())
+ cfg["actions"]["get_users"]["description"] = "Edited"
+ body = {"id": str(tool["id"]), "config": cfg}
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
+ # Changing the URL (or a secret value) needs edit_credentials.
+ cfg["actions"]["get_users"]["url"] = "https://api.example.com/v2"
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
+
+ def test_update_tool_actions_by_role(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolActions
+
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "ed", "editor")
+ _share(pg_conn, tool["id"], "vi", "viewer")
+ actions = [{"name": "act", "active": True, "require_approval": True}]
+ body = {"id": str(tool["id"]), "actions": actions}
+ assert _call(app, pg_conn, UpdateToolActions, "ed", json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["actions"][0]["require_approval"] is True
+ assert _call(app, pg_conn, UpdateToolActions, "vi", json=body).status_code == 403
+ assert _call(app, pg_conn, UpdateToolActions, "nobody", json=body).status_code == 404
+
+ def test_delete_tool_by_role_cleans_grants_and_settings(self, app, pg_conn):
+ from sqlalchemy import text
+
+ from docsgpt.api.user.tools.routes import DeleteTool
+
+ tool = _tool(pg_conn)
+ tid = str(tool["id"])
+ _share(pg_conn, tid, "ed", "editor")
+ set_settings(pg_conn, "tool", tid, {"editors_can_share": True}, OWNER)
+ assert _call(app, pg_conn, DeleteTool, "ed", json={"id": tid}).status_code == 403
+ assert _call(app, pg_conn, DeleteTool, "nobody", json={"id": tid}).status_code == 404
+ assert _call(app, pg_conn, DeleteTool, OWNER, json={"id": tid}).status_code == 200
+ assert _row(pg_conn, tid) is None
+ grants = pg_conn.execute(
+ text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
+ ).scalar()
+ settings_rows = pg_conn.execute(
+ text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
+ ).scalar()
+ assert grants == 0 and settings_rows == 0
+
+
+# ---------------------------------------------------------------------------
+# 5. POST /api/update_tool_status
+# ---------------------------------------------------------------------------
+class TestUpdateToolStatusRoles:
+ def test_owner_writes_status(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolStatus
+
+ tool = _tool(pg_conn, status=True)
+ body = {"id": str(tool["id"]), "status": False}
+ assert _call(app, pg_conn, UpdateToolStatus, OWNER, json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["status"] is False
+
+ def test_grantee_writes_personal_preference(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolStatus
+
+ tool = _tool(pg_conn, status=False)
+ _share(pg_conn, tool["id"], "bob", "viewer")
+ body = {"id": str(tool["id"]), "status": True}
+ assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["status"] is False # owner's switch untouched
+ prefs = UserToolPreferencesRepository(pg_conn)
+ assert prefs.in_chat_many("bob", [str(tool["id"])]) == {str(tool["id"]): True}
+
+ def test_grantee_without_use_in_own_is_forbidden(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolStatus
+
+ tool = _tool(pg_conn)
+ _share(pg_conn, tool["id"], "bob", "viewer")
+ set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
+ body = {"id": str(tool["id"]), "status": True}
+ assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 403
+ assert _call(app, pg_conn, UpdateToolStatus, "nobody", json=body).status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# 4. MCP save
+# ---------------------------------------------------------------------------
+def _mcp_tool(conn, url="https://mcp.example.com/mcp", secrets=None, auth_type="bearer"):
+ cfg = {"server_url": url, "auth_type": auth_type, "transport_type": "http"}
+ if secrets:
+ cfg["encrypted_credentials"] = encrypt_credentials(secrets, OWNER)
+ return UserToolsRepository(conn).create(
+ OWNER, "mcp_tool", config=cfg, display_name="M", custom_name="M", description="",
+ actions=[{"name": "old", "active": True}], status=True,
+ )
+
+
+class TestMCPSaveAccess:
+ def _save(self, app, conn, user, body, discovered=None):
+ from docsgpt.api.user.tools.mcp import MCPServerSave
+
+ fake = MagicMock()
+ fake.get_actions_metadata.return_value = discovered or [{"name": "t1"}]
+ with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
+ resp = _call(app, conn, MCPServerSave, user, json=body)
+ return resp, cls
+
+ def _count(self, conn):
+ from sqlalchemy import text
+
+ return conn.execute(text("SELECT count(*) FROM user_tools WHERE name = 'mcp_tool'")).scalar()
+
+ def test_editor_updates_owner_row_no_duplicate(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "Renamed",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
+ "transport_type": "http"}}
+ resp, cls = self._save(app, pg_conn, "ed", body)
+ assert resp.status_code == 200, resp.json
+ assert resp.json["id"] == str(tool["id"])
+ assert self._count(pg_conn) == 1
+ row = _row(pg_conn, tool["id"])
+ assert row["display_name"] == "Renamed"
+ # Stored creds were reused for discovery and kept, encrypted with the owner's key.
+ assert cls.call_args.kwargs.get("user_id") == OWNER
+ assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
+ assert decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
+
+ def test_viewer_and_stranger_cannot_save_or_duplicate(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "vi", "viewer")
+ body = {"id": str(tool["id"]), "displayName": "X",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
+ "transport_type": "http", "bearer_token": "new"}}
+ assert self._save(app, pg_conn, "vi", body)[0].status_code == 403
+ assert self._save(app, pg_conn, "nobody", body)[0].status_code == 404
+ assert self._count(pg_conn) == 1
+
+ def test_host_change_without_credentials_is_rejected(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
+ "transport_type": "http"}}
+ resp, cls = self._save(app, pg_conn, OWNER, body)
+ assert resp.status_code == 400
+ assert resp.json["message"] == "Enter credentials for the new server"
+ assert not cls.called
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
+
+ def test_host_change_with_new_credentials_replaces_them(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
+ "transport_type": "http", "bearer_token": "tok2"}}
+ resp, _ = self._save(app, pg_conn, OWNER, body)
+ assert resp.status_code == 200
+ row = _row(pg_conn, tool["id"])
+ assert decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok2"}
+
+ def test_editor_blocked_when_credentials_switch_off(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
+ "transport_type": "http"}}
+ assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
+
+ def test_editor_oauth_reconnect_is_owner_only(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
+ "transport_type": "http", "oauth_task_id": "task-1"}}
+ resp, _ = self._save(app, pg_conn, "ed", body)
+ assert resp.status_code == 403
+
+ def test_editor_oauth_edit_without_reconnect_keeps_actions(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "Renamed",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
+ "transport_type": "http"}}
+ resp, _ = self._save(app, pg_conn, "ed", body)
+ assert resp.status_code == 200, resp.json
+ row = _row(pg_conn, tool["id"])
+ assert row["display_name"] == "Renamed"
+ assert [a["name"] for a in row["actions"]] == ["old"]
+
+ def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
+ from docsgpt.api.user.tools.mcp import MCPAuthStatus
+
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "vi", "viewer")
+ resp = _call(app, pg_conn, MCPAuthStatus, "vi", method="get", path="/api/mcp_server/auth_status")
+ assert resp.json["statuses"] == {str(tool["id"]): "configured"}
+
+
+class TestMCPTestEndpointAccess:
+ def _test(self, app, conn, user, body):
+ from docsgpt.api.user.tools.mcp import TestMCPServerConfig
+
+ fake = MagicMock()
+ fake.test_connection.return_value = {"success": True, "tools_count": 1, "tools": []}
+ with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
+ resp = _call(app, conn, TestMCPServerConfig, user, json=body)
+ return resp, cls
+
+ def _body(self, tool, url="https://mcp.example.com/mcp", **extra):
+ return {"id": str(tool["id"]), "config": {"server_url": url, "auth_type": "bearer",
+ "transport_type": "http", **extra}}
+
+ def test_empty_secret_uses_stored_one_as_owner(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ resp, cls = self._test(app, pg_conn, "ed", self._body(tool))
+ assert resp.status_code == 200 and resp.json["success"] is True
+ assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
+ assert cls.call_args.kwargs["user_id"] == OWNER
+
+ def test_host_change_without_secret_is_400(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp"))
+ assert resp.status_code == 400
+ assert resp.json["message"] == "Enter credentials for the new server"
+ assert not cls.called
+ resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp",
+ bearer_token="t2"))
+ assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "t2"}
+
+ def test_viewer_and_stranger_denied(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "vi", "viewer")
+ assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
+ assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
+
+ def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/mcp",
+ "auth_type": "oauth", "transport_type": "http"}}
+ resp, cls = self._test(app, pg_conn, "ed", body)
+ assert resp.status_code == 403 and not cls.called
diff --git a/tests/api/user/workflows/test_routes_coverage.py b/tests/api/user/workflows/test_routes_coverage.py
index e1247053..716c961c 100644
--- a/tests/api/user/workflows/test_routes_coverage.py
+++ b/tests/api/user/workflows/test_routes_coverage.py
@@ -474,20 +474,24 @@ class TestWorkflowDetailPut:
def test_validation_failure_returns_400(self, app, pg_conn):
from docsgpt.api.user.workflows.routes import WorkflowDetail
+ from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+ # Access is checked before the graph is validated (validation runs as
+ # the workflow's owner), so the workflow has to exist.
+ wid = str(WorkflowsRepository(pg_conn).create("u1", "wf")["id"])
body = {
"name": "bad",
"nodes": [{"id": "end1", "type": "end"}],
"edges": [],
}
with _patch_wf_db(pg_conn), app.test_request_context(
- "/api/workflows/abc",
+ f"/api/workflows/{wid}",
method="PUT",
json=body,
):
from flask import request
request.decoded_token = {"sub": "u1"}
- response = WorkflowDetail().put("abc")
+ response = WorkflowDetail().put(wid)
assert response.status_code == 400
def test_updates_workflow(self, app, pg_conn):
From 38bfb197398a4c86c4c263edc1e9ca516125c33f Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 11:11:33 +0400
Subject: [PATCH 3/9] Frontend fixes
---
docsgpt/api/user/sources/routes.py | 11 +-
frontend/DESIGN.md | 110 ++++++++++++++---
frontend/src/agents/AgentCard.test.tsx | 25 ++++
frontend/src/agents/AgentCard.tsx | 19 ++-
frontend/src/agents/AgentRouteGuard.test.tsx | 7 +-
frontend/src/agents/AgentRouteGuard.tsx | 6 +-
frontend/src/agents/NewAgent.tsx | 3 -
.../components/GuardrailsSection.test.tsx | 7 +-
.../agents/components/GuardrailsSection.tsx | 17 ++-
.../src/agents/workflow/WorkflowBuilder.tsx | 3 +-
frontend/src/components/ConfigFields.test.tsx | 64 ++++++++++
frontend/src/components/ConfigFields.tsx | 8 +-
frontend/src/components/RoleBadge.test.tsx | 52 ++++++++
frontend/src/components/RoleBadge.tsx | 29 +++++
.../src/components/ViewOnlyNotice.test.tsx | 35 ++++++
frontend/src/components/ViewOnlyNotice.tsx | 28 +++++
frontend/src/locale/de.json | 15 +--
frontend/src/locale/en.json | 15 +--
frontend/src/locale/es.json | 15 +--
frontend/src/locale/jp.json | 15 +--
frontend/src/locale/ru.json | 15 +--
frontend/src/locale/zh-TW.json | 15 +--
frontend/src/locale/zh.json | 15 +--
.../src/modals/AgentDetailsModal.test.tsx | 3 +
frontend/src/modals/AgentDetailsModal.tsx | 4 +-
frontend/src/modals/MCPServerModal.test.tsx | 17 ++-
frontend/src/modals/MCPServerModal.tsx | 12 +-
.../src/preferences/PromptsModal.test.tsx | 43 +++++++
frontend/src/preferences/PromptsModal.tsx | 33 +++--
.../src/settings/SourceConfigModal.test.tsx | 15 ++-
frontend/src/settings/SourceConfigModal.tsx | 11 +-
frontend/src/settings/Sources.test.tsx | 9 ++
frontend/src/settings/Sources.tsx | 19 +--
frontend/src/settings/Teams.test.tsx | 64 +++++++++-
frontend/src/settings/Teams.tsx | 116 +++++++++---------
frontend/src/settings/ToolConfig.test.tsx | 34 ++++-
frontend/src/settings/ToolConfig.tsx | 5 +
frontend/src/settings/Tools.test.tsx | 24 +++-
frontend/src/settings/Tools.tsx | 57 ++++-----
frontend/src/teams/ShareToTeamModal.test.tsx | 43 ++++++-
frontend/src/teams/ShareToTeamModal.tsx | 63 +++++-----
tests/api/user/sources/test_source_roles.py | 17 ++-
42 files changed, 834 insertions(+), 284 deletions(-)
create mode 100644 frontend/src/components/ConfigFields.test.tsx
create mode 100644 frontend/src/components/RoleBadge.test.tsx
create mode 100644 frontend/src/components/RoleBadge.tsx
create mode 100644 frontend/src/components/ViewOnlyNotice.test.tsx
create mode 100644 frontend/src/components/ViewOnlyNotice.tsx
diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py
index abbb0492..fab6968a 100644
--- a/docsgpt/api/user/sources/routes.py
+++ b/docsgpt/api/user/sources/routes.py
@@ -77,9 +77,10 @@ def _get_provider_from_remote_data(remote_data):
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
"""Add ``access`` + ``allowed_actions`` to a listed source row.
- The source's behaviour ``config`` is dropped when the caller's role may
- not ``view_config`` (a viewer when the owner turned
- ``viewers_can_see_config`` off).
+ The source's behaviour ``config`` is cut down to its ``kind`` when the
+ caller's role may not ``view_config`` (a viewer when the owner turned
+ ``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to
+ pick the wiki / graph view.
Args:
entry: The row as the list endpoint builds it.
@@ -91,7 +92,9 @@ def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -
"""
payload = payload_for("source", access, switches)
if "view_config" not in payload["allowed_actions"]:
- entry.pop("config", None)
+ config = entry.get("config")
+ if config is not None:
+ entry["config"] = {"kind": (config or {}).get("kind", "classic")}
entry.update(payload)
return entry
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 4e918b93..82c412cb 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -17,7 +17,8 @@ from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
go in CSS custom properties or, when unavoidable, an inline style with a
disable comment. Conditional classes go through `cn(...)`, never a template
literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts).
-Every user-visible string, attributes included (`aria-label`, IconButton
+What a role may do is gated with `can(item, action)` and a refused action is
+not rendered (see Access and roles). Every user-visible string, attributes included (`aria-label`, IconButton
`label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven
locales (`de en es jp ru zh zh-TW`); admin pages are English by design.
Interpolated user text (a name, a timezone, a date) passes `interpolation: {
@@ -228,13 +229,14 @@ pill`, the only `outline-primary` on the agent pages themselves (the Access
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
at the end of the title row, through `SectionShell titleAction`: Access
- details and Share with team on Overview, and Preview until the agent is
+ details and Share with team on Overview (each only when the role allows
+ it, see Access and roles), and Preview until the agent is
published (before that the preview only says "Publish to preview"). So the
row never holds more than three buttons and fits a phone, where the main
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
spans the stacked row below `sm`). The workflow builder's toolbar has no
title row and keeps its ⋯ at the row's end, holding Edit details, Access
- details and Delete (see Workflow builder). The
+ details, Share with team and Delete (see Workflow builder). The
agent's Delete is in Overview's danger zone; only the workflow builder's
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
@@ -294,7 +296,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
inset because a scrolling column clips an outset ring), so pages pass
nothing for it. Status badges go beside the button, not inside it, or the
- hover underline runs under them.
+ hover underline runs under them. Because the ring comes from the Card,
+ `section-toggle` only goes inside a Card: a collapsible group in a Modal
+ or drawer (Share's Access settings) is the inline `link sm` disclosure
+ toggle above.
- Drop `text-white` on default and destructive buttons; the foreground token
already provides it. Drop `disabled:cursor-not-allowed` on buttons; the
base disables pointer events. Fields are the other way round (see Focus,
@@ -466,7 +471,10 @@ pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx
maps schedule and run statuses to Badge variants), trace chips and statuses,
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
-→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
+→ Users); every other role is `neutral`. A shared asset's tile (agent,
+source, tool, prompt) shows the caller's role with `components/RoleBadge`: a
+`neutral` Badge with `Users` first (Editor, Viewer, from `roleOf()`); your own
+assets show none. Chips that show code (token scopes) pass `font-mono`, and
stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
@@ -603,6 +611,13 @@ state assignment) is not a bare row: each of its fields has a floating
label. Never hand-build a label above a field, a
hand-positioned floating label, or a `div.flex-col` + `Label` + `` stack.
+A saved secret (an API key, a tool's credential, a custom header value) never
+comes back from the API, for any role. Its field is empty and `type="password"`;
+in a FormField the saved state is the `hint` ("Saved. Leave empty to keep
+it."), because a placeholder is hidden while the label rests. Only a
+label-less table cell carries it as the placeholder
+(`settings.tools.savedSecretPlaceholder`). Never a `••••` placeholder.
+
### SettingRow (`ui/setting-row.tsx`)
A setting with a control on the right (a Switch, a short Input) is
@@ -632,7 +647,10 @@ description use a Switch in a SettingRow instead.
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
-it. The track is `primary` when on and `bg-input` when off, with a white
+it. A tile's own on/off (the tool tile's "In my chats") is the one exception:
+a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the
+tile's bottom-right. For a shared tool it is the caller's own preference,
+never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white
thumb in both themes (see Elevation).
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
@@ -680,6 +698,11 @@ row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild
variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around
each `Link`, with `aria-current="page"` on the current one.
+Filtering a list by kind (a team's shared resources, Share's People) is this
+`xs` group in its muted track, each item `{label} {formatCount(n)}`, beside a
+`SearchInput size="sm"` (`w-full sm:w-56`); no match is `EmptyState size="xs"
+illustration="none"`.
+
### Separator (`ui/separator.tsx`)
A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative
@@ -717,7 +740,9 @@ height of its own). `label` puts a muted caption under the ring (a long job:
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
the Spinner sizes (`default` unless set): `sm` inside a picker (the
MultiSelect popover's list), `lg` for a full panel (a remote device's
-config). Spinners
+config). A role guard shows it while it resolves, then redirects
+silently: `AdminRoute` at `fill="screen"`, `AgentRouteGuard` at
+`fill="parent"` (it renders inside the app shell's scroll column) (see Access and roles). Spinners
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
@@ -838,6 +863,43 @@ variant="destructive"` above the form, not text in or beside the button.
publish validation) is a destructive `Alert` floating at `z-20` on an opaque
`bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast
would truncate each error to one line and dismiss itself.
+- A switch moves at once and flips back when the server refuses, with a
+ destructive toast (an Alert inside a modal). A delete removes its row only
+ after the server confirms; a 403 shows `errors.forbidden`.
+
+### Access and roles
+
+Agents, sources, tools and prompts come to the UI with `access` and
+`allowed_actions`. Gate every control with `can(item, action)`
+(`utils/accessUtils`; `canAgent` in `agents/agentAccess.ts` for agents, which
+also refuses Logs, Schedules and Pin on a draft), never on `ownership`,
+`team_access` or the user id.
+
+- An action the role doesn't allow is not rendered. A menu builds its
+ options from `can()`, and a ⋯ with no options is not drawn; a footer or
+ row left empty goes, with its `Separator`. Never a disabled Save or a
+ disabled menu item for a role.
+- A control stays visible but `disabled` only when it shows state the caller
+ should still see (a switch's on/off, a policy's value). Then the reason is
+ on screen (an Alert or a FormField `hint`), never a bare grey control. A
+ control that can't apply to the caller at all (a tool's "In my chats" when
+ the grant doesn't allow it in their chats) is hidden, not disabled.
+- A view-only form opens with the same fields and `ViewOnlyNotice`
+ (`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and
+ `common.viewOnlyNotice`) as its first child. Where only part of an editable
+ form is locked (a tool's credentials when the owner turned off "Editors can
+ change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close.
+ Fields are `disabled` (a group: `
`);
+ long text the user reads or copies (a prompt, a chunk) is `readOnly`, so it
+ keeps full contrast and scrolls. Titles and menu items swap Edit and
+ `Pencil` for View and `Eye`.
+- Source views follow the same rule: every write (Add file, Sync, Add chunk,
+ Edit, Delete) shows only with `can(source, 'edit')`; reading, copying and
+ paging stay.
+- A page the role can't open is refused twice: its tabs and section items
+ are filtered by the same action (`AgentPageHeader`, `navigation/sections`),
+ and its route is wrapped in a guard (`AgentRouteGuard`) that redirects to
+ the list as a deep-link fallback, with `LoadingState` while it resolves.
### Alert (`ui/alert.tsx`)
@@ -892,8 +954,7 @@ bordered list for one). Rows are `px-4 py-3`, the title `text-sm
font-medium`. `interactive` (with `asChild` around a ` ` or ``)
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
+resources), exactly as a selected TableRow (see Table). 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
@@ -989,8 +1050,8 @@ The title is for chats only: the open conversation's name, or the agent's
name on a new agent chat, with the agent's `Avatar` in front. A plain new chat
has no title. So do section pages (settings, admin, an agent's pages), because
`SectionShell` already draws their title. When the chat has actions, the title
-is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (owned
-agents), Share, Rename and Delete. Rename edits the name in place, as the
+is a `ghost sm` Button that opens a `DropdownMenu` with Edit agent (a role
+that may open its edit page), Share, Rename and Delete. Rename edits the name in place, as the
sidebar row does. A title with no actions (a shared agent's new chat) is
plain text.
@@ -1060,8 +1121,10 @@ variant="field"` over a `CommandList` of `CommandItem`s, so the arrow keys
chunk's previous / next (`IconButton ghost-muted icon-sm pill`, and the
arrow keys), then Edit (`outline sm pill`, `Pencil` first) and an
`ActionMenu size="toolbar"` (Copy text; a chunk's also has Delete, a
- destructive item). A wiki page has Edit (editors only) and the same menu
- with Copy text, and no previous / next; the navigator walks them.
+ destructive item). A wiki page has Edit and the same menu
+ with Copy text, and no previous / next; the navigator walks them. Edit,
+ Delete and Add chunk show only to a role that may edit (see Access and
+ roles).
- **Read in the page, edit in a drawer**: Edit and Add chunk open
`SourceEditSheet`, a right `Sheet size="wide"` (a working surface): title
and a mono description (path, version, tokens), then any `fields` edited
@@ -1115,7 +1178,8 @@ pill`) and Save (`default lg pill`, off until the draft or a field changes, and
source chunks as `filled sm interactive` tiles. A tile opens a read drawer
(`Sheet size="detail"`, one record) with the chunk rendered and the entity's
name marked in the brand tint (`bg-secondary`), and Open in Files and Edit
- (the same `SourceEditSheet`) in its footer; Cancel or Discard in that edit
+ (the same `SourceEditSheet`) in its footer, which is omitted when neither
+ applies; Cancel or Discard in that edit
drawer returns to the read drawer, Save closes both, and closing it leaves
the node selected. The relationship list is capped server-side: its heading
counts the true total (`relationships_total`) and, when the list is partial,
@@ -1158,8 +1222,8 @@ opens the workflow details. The status Badge follows (`success` Published,
`neutral` Draft until the first save), then the tabs. On the right: "Unsaved
changes" as muted `text-sm` meta while there are any, Preview, Save, and the
⋯ (`ActionMenu size="toolbar"`): Edit details first (the same drawer, for
-anyone who doesn't try the name), then Access details and Delete once the
-workflow is saved.
+anyone who doesn't try the name), then Access details, Share with team and
+Delete once the workflow is saved, each only when the role allows it.
The workflow details are a right `Sheet size="default"`
(`components/WorkflowDetailsSheet.tsx`), built like AgentPreviewSheet (header,
@@ -1253,6 +1317,13 @@ ScheduleFormModal's editable name, the search palette). A step heading
under a Back button uses the title's classes (`text-xl leading-tight
font-semibold`), not a larger size.
+A list in a modal that can grow long (Share's People) shows the first three
+once it passes five, a `link inline` "Show all N" (12px `ArrowRight`) in its
+SectionHeader's `actions` and a muted `text-xs` "and N more" line, counts
+through `formatCount`. The full list is a second step: a `ghost sm` Back with
+`ArrowLeft`, the title-class heading, then the search and kind filter (see
+ToggleGroup), with `hideTitle` on that step only.
+
Buttons go in `footer`, never in `children`. The footer stacks full width on
phones (primary on top) and sits in a right-aligned row from `sm` up. The
standard pair is `footer={ {
]);
});
+ it('swaps Edit for View (Eye) when the role can open but not edit', async () => {
+ await render(agentWith('viewer', ['use', 'pin', 'view']), 'team');
+ const items = await openMenu();
+ const view = items.find((i) => i.textContent === 'agents.view');
+ expect(view).toBeDefined();
+ expect(view?.querySelector('svg')?.getAttribute('class')).toContain(
+ 'lucide-eye',
+ );
+ expect(items.map((i) => i.textContent)).not.toContain('agents.edit');
+ });
+
+ it('shows the role on a shared tile as a neutral Users Badge', async () => {
+ await render(agentWith('editor', EDITOR_ACTIONS), 'team');
+ const badge = container.querySelector(
+ '[data-testid="role-badge"]',
+ );
+ expect(badge?.dataset.variant).toBe('neutral');
+ expect(badge?.textContent).toBe('teamAccess.editor');
+ });
+
+ it('shows no role badge on an own agent', async () => {
+ await render(agentWith('owner', OWNER_ACTIONS), 'user');
+ expect(container.querySelector('[data-testid="role-badge"]')).toBeNull();
+ });
+
it('gives a viewer Pin only', async () => {
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
expect(await menuLabels()).toEqual(['agents.card.pin']);
diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx
index f08811c4..ccd3c849 100644
--- a/frontend/src/agents/AgentCard.tsx
+++ b/frontend/src/agents/AgentCard.tsx
@@ -6,6 +6,7 @@ import {
Activity,
Copy,
Download,
+ Eye,
Folder,
Pencil,
Pin,
@@ -15,8 +16,8 @@ import {
} from 'lucide-react';
import userService from '../api/services/userService';
+import RoleBadge from '../components/RoleBadge';
import { Avatar } from '../components/ui/avatar';
-import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
@@ -96,8 +97,13 @@ export default function AgentCard({
},
]
: []),
+ // Opening the editor is `view`; a role that can't `edit` gets it as View.
...(can(agent, 'view')
- ? [{ icon: Pencil, label: t('agents.edit'), onClick: openEditor }]
+ ? [
+ can(agent, 'edit')
+ ? { icon: Pencil, label: t('agents.edit'), onClick: openEditor }
+ : { icon: Eye, label: t('agents.view'), onClick: openEditor },
+ ]
: []),
...(can(agent, 'export')
? [
@@ -333,14 +339,7 @@ export default function AgentCard({
)}
{/* 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' && (
-
-
- {agent.team_access === 'editor'
- ? t('agents.teamBadge.editor')
- : t('agents.teamBadge.viewer')}
-
- )}
+
{
}),
);
await render('view', '/agents/manage/edit/a1');
- // Nothing of the page while the agent loads.
+ // Nothing of the page while the agent loads, only the loading ring.
expect(shows('page')).toBe(false);
+ const loading = container.querySelector('[data-slot="loading-state"]');
+ expect(loading?.getAttribute('data-fill')).toBe('parent');
+ expect(loading?.querySelector('[role="status"]')).not.toBeNull();
await act(async () =>
resolve({
ok: true,
@@ -91,6 +94,8 @@ describe('AgentRouteGuard', () => {
);
expect(shows('page')).toBe(false);
expect(shows('list')).toBe(true);
+ // The redirect itself is silent.
+ expect(container.querySelector('[data-slot="loading-state"]')).toBeNull();
});
it('lets an editor open the page', async () => {
diff --git a/frontend/src/agents/AgentRouteGuard.tsx b/frontend/src/agents/AgentRouteGuard.tsx
index ed487d1f..8c0ec228 100644
--- a/frontend/src/agents/AgentRouteGuard.tsx
+++ b/frontend/src/agents/AgentRouteGuard.tsx
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { Navigate, useParams } from 'react-router-dom';
import userService from '../api/services/userService';
+import { LoadingState } from '../components/ui/loading-state';
import {
selectAgents,
selectSelectedAgent,
@@ -24,7 +25,8 @@ type AgentRouteGuardProps = {
*
* 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.
+ * renders until it knows (a loading ring stands in), 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.
*
@@ -74,7 +76,7 @@ export default function AgentRouteGuard({
let agent: Agent | null | undefined = stored;
if (!agent) {
- if (fetched?.id !== agentId) return null;
+ if (fetched?.id !== agentId) return ;
agent = fetched.agent;
}
if (!agent || !canAgent(agent, action))
diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx
index 9176bd44..fc82c0e3 100644
--- a/frontend/src/agents/NewAgent.tsx
+++ b/frontend/src/agents/NewAgent.tsx
@@ -1492,9 +1492,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
// Guardrails are policy (`edit_policy`): editors and the owner
// change them; anyone else sees them read-only.
disabled={!canEditPolicy}
- disabledNotice={
- canEditPolicy ? undefined : t('agents.form.guardrails.readOnly')
- }
onChange={(guardrails) =>
setAgent({
...agent,
diff --git a/frontend/src/agents/components/GuardrailsSection.test.tsx b/frontend/src/agents/components/GuardrailsSection.test.tsx
index 521c54d1..b158fe4e 100644
--- a/frontend/src/agents/components/GuardrailsSection.test.tsx
+++ b/frontend/src/agents/components/GuardrailsSection.test.tsx
@@ -135,7 +135,6 @@ describe('GuardrailsSection', () => {
value={config}
onChange={() => undefined}
token="tok"
- disabledNotice="Read only"
{...props}
/>,
);
@@ -232,9 +231,11 @@ describe('GuardrailsSection', () => {
it('shows the section notices as polite or warning alerts', async () => {
await render({ disabled: true });
+ // View-only: the shared quiet note, not an announced status.
const readOnly = q('guardrails-read-only');
- expect(readOnly?.getAttribute('role')).toBe('status');
- expect(readOnly?.textContent).toContain('Read only');
+ expect(readOnly?.getAttribute('role')).toBe('note');
+ expect(readOnly?.dataset.variant).toBe('default');
+ expect(readOnly?.textContent).toBe('common.viewOnlyNotice');
const instance = q('guardrails-instance-disabled');
expect(instance?.getAttribute('role')).toBe('alert');
expect(instance?.className).toContain('text-warning');
diff --git a/frontend/src/agents/components/GuardrailsSection.tsx b/frontend/src/agents/components/GuardrailsSection.tsx
index cac93cf6..5e1fe9ff 100644
--- a/frontend/src/agents/components/GuardrailsSection.tsx
+++ b/frontend/src/agents/components/GuardrailsSection.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { ChevronRight, Info, Lock, TriangleAlert } from 'lucide-react';
+import { ChevronRight, Info, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
+import ViewOnlyNotice from '@/components/ViewOnlyNotice';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -101,9 +102,11 @@ type Props = {
value?: GuardrailsConfig;
onChange: (next: GuardrailsConfig) => void;
token: string | null;
+ /**
+ * The caller's role can't change the policy: the controls still show its
+ * state, disabled, under the shared view-only notice.
+ */
disabled?: boolean;
- /** Why the controls are read-only, shown in place of a silent lockout. */
- disabledNotice?: string;
};
export default function GuardrailsSection({
@@ -111,7 +114,6 @@ export default function GuardrailsSection({
onChange,
token,
disabled = false,
- disabledNotice,
}: Props) {
const { t } = useTranslation();
const [expanded, setExpanded] = React.useState(false);
@@ -287,12 +289,7 @@ export default function GuardrailsSection({
/>
)}
- {disabled && disabledNotice && (
-
-
- {disabledNotice}
-
- )}
+ {disabled && }
{instanceDisabled && (
diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx
index 0d765d88..00bdf3c5 100644
--- a/frontend/src/agents/workflow/WorkflowBuilder.tsx
+++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx
@@ -1534,9 +1534,10 @@ function WorkflowBuilderInner() {
// 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.
+ // Without it the Save/Publish button isn't rendered at all.
const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish');
const isPrimaryActionDisabled =
- !canSubmit || isPublishing || (canManageAgent && !hasSavableChanges);
+ isPublishing || (canManageAgent && !hasSavableChanges);
const primaryActionLabel = canManageAgent
? t('agents.form.buttons.save')
: t('agents.form.buttons.publish');
diff --git a/frontend/src/components/ConfigFields.test.tsx b/frontend/src/components/ConfigFields.test.tsx
new file mode 100644
index 00000000..3965ea0c
--- /dev/null
+++ b/frontend/src/components/ConfigFields.test.tsx
@@ -0,0 +1,64 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import ConfigFields from './ConfigFields';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('ConfigFields secrets', () => {
+ 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();
+ });
+
+ const render = async (hasEncryptedCredentials: boolean) => {
+ await act(async () => {
+ root.render(
+ undefined}
+ isEditing
+ hasEncryptedCredentials={hasEncryptedCredentials}
+ />,
+ );
+ });
+ };
+
+ it('a saved secret stays empty, with the saved state as the hint', async () => {
+ await render(true);
+ const input = container.querySelector('input');
+ expect(input?.type).toBe('password');
+ expect(input?.value).toBe('');
+ expect(input?.placeholder).not.toContain('•');
+ expect(container.textContent).toContain('common.savedSecretHint');
+ });
+
+ it('an unsaved secret has no saved hint', async () => {
+ await render(false);
+ expect(container.textContent).not.toContain('common.savedSecretHint');
+ expect(container.querySelector('input')?.placeholder).toBe(
+ 'Your provider key',
+ );
+ });
+});
diff --git a/frontend/src/components/ConfigFields.tsx b/frontend/src/components/ConfigFields.tsx
index ab0d7db2..ac1f798e 100644
--- a/frontend/src/components/ConfigFields.tsx
+++ b/frontend/src/components/ConfigFields.tsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import { ConfigRequirements } from '../modals/types';
import { FormField, type FormFieldProps } from './ui/form-field';
@@ -43,6 +44,7 @@ export default function ConfigFields({
hasEncryptedCredentials = false,
labelSurface,
}: ConfigFieldsProps) {
+ const { t } = useTranslation();
const sortedFields = useMemo(
() =>
Object.entries(configRequirements).sort(
@@ -59,9 +61,10 @@ export default function ConfigFields({
if (!shouldShowField(spec, values)) return null;
const value = values[key] ?? spec.default ?? '';
+ // A saved secret never comes back: the field stays empty and the
+ // hint says it is saved.
const hasEncrypted =
isEditing && spec.secret && hasEncryptedCredentials;
- const placeholder = hasEncrypted ? '••••••••' : '';
const error = errors[key] || undefined;
if (spec.enum) {
@@ -98,6 +101,7 @@ export default function ConfigFields({
key={key}
label={spec.label || key}
required={!!spec.required}
+ hint={hasEncrypted ? t('common.savedSecretHint') : undefined}
error={error}
labelSurface={labelSurface}
>
@@ -122,7 +126,7 @@ export default function ConfigFields({
onChange(key, v);
}
}}
- placeholder={placeholder || spec.description || ''}
+ placeholder={spec.description || ''}
min={spec.type === 'number' ? 1 : undefined}
max={
spec.type === 'number' && key === 'timeout' ? 300 : undefined
diff --git a/frontend/src/components/RoleBadge.test.tsx b/frontend/src/components/RoleBadge.test.tsx
new file mode 100644
index 00000000..71e93cd4
--- /dev/null
+++ b/frontend/src/components/RoleBadge.test.tsx
@@ -0,0 +1,52 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import RoleBadge from './RoleBadge';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('RoleBadge', () => {
+ 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();
+ });
+
+ it('shows the role as a neutral Badge with the Users icon first', async () => {
+ await act(async () =>
+ root.render( ),
+ );
+ const badge = container.querySelector('[data-slot="badge"]');
+ expect(badge?.dataset.variant ?? badge?.className).toMatch(/neutral|muted/);
+ const icon = badge?.firstElementChild;
+ expect(icon?.getAttribute('class')).toContain('lucide-users');
+ expect(icon?.getAttribute('class')).not.toContain('size-3');
+ expect(badge?.textContent).toBe('teamAccess.editor');
+ });
+
+ it('reads the legacy team fields through roleOf', async () => {
+ await act(async () =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.textContent).toBe('teamAccess.viewer');
+ });
+
+ it('renders nothing for the caller’s own item', async () => {
+ await act(async () => root.render( ));
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/frontend/src/components/RoleBadge.tsx b/frontend/src/components/RoleBadge.tsx
new file mode 100644
index 00000000..59d6b3fd
--- /dev/null
+++ b/frontend/src/components/RoleBadge.tsx
@@ -0,0 +1,29 @@
+import { Users } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { roleOf, type AccessFields } from '../utils/accessUtils';
+import { Badge } from './ui/badge';
+
+/**
+ * The caller's role on a team-shared asset tile (agent, source, tool): a
+ * neutral Badge with the Users icon and Editor or Viewer. Nothing for the
+ * caller's own items.
+ */
+export default function RoleBadge({
+ item,
+ className,
+}: {
+ item: AccessFields;
+ /** Placement only (a tile pins it beside its menu). */
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const role = roleOf(item);
+ if (role === 'owner') return null;
+ return (
+
+
+ {role === 'editor' ? t('teamAccess.editor') : t('teamAccess.viewer')}
+
+ );
+}
diff --git a/frontend/src/components/ViewOnlyNotice.test.tsx b/frontend/src/components/ViewOnlyNotice.test.tsx
new file mode 100644
index 00000000..d24d4872
--- /dev/null
+++ b/frontend/src/components/ViewOnlyNotice.test.tsx
@@ -0,0 +1,35 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import ViewOnlyNotice from './ViewOnlyNotice';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('ViewOnlyNotice', () => {
+ 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();
+ });
+
+ it('is a quiet default Alert (role="note") with the shared sentence', async () => {
+ await act(async () => root.render( ));
+ const alert = container.querySelector('[data-slot="alert"]');
+ expect(alert?.getAttribute('role')).toBe('note');
+ expect(alert?.getAttribute('data-variant')).toBe('default');
+ expect(alert?.querySelector('svg')).not.toBeNull();
+ expect(alert?.textContent).toBe('common.viewOnlyNotice');
+ });
+});
diff --git a/frontend/src/components/ViewOnlyNotice.tsx b/frontend/src/components/ViewOnlyNotice.tsx
new file mode 100644
index 00000000..a2709090
--- /dev/null
+++ b/frontend/src/components/ViewOnlyNotice.tsx
@@ -0,0 +1,28 @@
+import { Lock } from 'lucide-react';
+import type React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Alert, AlertDescription } from './ui/alert';
+
+/**
+ * The first child of a form the caller's role can only view: the same
+ * fields, disabled, under one quiet note. Every view-only form shows this
+ * one sentence, so a role reads the same everywhere. `message` replaces it
+ * only where part of an editable form is locked (a tool's credentials).
+ */
+export default function ViewOnlyNotice({
+ message,
+ ...props
+}: Omit, 'children' | 'variant'> & {
+ message?: string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {message ?? t('common.viewOnlyNotice')}
+
+
+ );
+}
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 8a6b3c81..fe774f70 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -10,6 +10,12 @@
"loading": "Wird geladen...",
"retry": "Erneut versuchen",
"cancel": "Abbrechen",
+ "common": {
+ "close": "Schließen",
+ "viewOnlyNotice": "Du kannst dies ansehen, aber deine Rolle kann es nicht ändern.",
+ "credentialsLockedNotice": "Nur der Eigentümer kann die Zugangsdaten dieses Tools ändern.",
+ "savedSecretHint": "Gespeichert. Leer lassen, um es zu behalten."
+ },
"help": "Hilfe",
"emailUs": "E-Mail senden",
"documentation": "Dokumentation",
@@ -396,7 +402,6 @@
"subtitle": "Konfiguriere, wie \"{{name}}\" in Chunks aufgeteilt und abgerufen wird.",
"subtitleGeneric": "Konfiguriere, wie diese Quelle in Chunks aufgeteilt und abgerufen wird.",
"save": "Speichern",
- "readOnly": "Du hast nur Lesezugriff auf diese geteilte Quelle und kannst ihre Konfiguration nicht ändern.",
"chunkingChangeHint": "Du hast eine Chunking-Einstellung geändert. Nach dem Speichern muss diese Quelle erneut indexiert werden, damit die Änderung wirkt.",
"prescreenInvalidHint": "Prüfe die Vorfilter-Werte: Die abzurufenden Kandidaten müssen mindestens der Chunk-Anzahl entsprechen, und die zu behaltenden Chunks dürfen die Kandidatenzahl nicht überschreiten.",
"reingestRequired": "Deine Änderungen wurden gespeichert. Die geänderten Chunking-Einstellungen wirken erst nach einem erneuten Indexieren. Jetzt erneut indexieren?",
@@ -1778,6 +1783,7 @@
"agents": {
"title": "Agenten",
"edit": "Bearbeiten",
+ "view": "Ansehen",
"card": {
"pin": "Agent anheften",
"unpin": "Agent lösen",
@@ -1816,10 +1822,6 @@
"team": "Team",
"shared": "Mit mir geteilt"
},
- "teamBadge": {
- "editor": "Bearbeiter",
- "viewer": "Betrachter"
- },
"sections": {
"template": {
"title": "Von DocsGPT",
@@ -1998,8 +2000,7 @@
"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."
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 520b6108..9a6f4768 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -10,6 +10,12 @@
"loading": "Loading...",
"retry": "Retry",
"cancel": "Cancel",
+ "common": {
+ "close": "Close",
+ "viewOnlyNotice": "You can view this, but your role can't change it.",
+ "credentialsLockedNotice": "Only the owner can change this tool's credentials.",
+ "savedSecretHint": "Saved. Leave empty to keep it."
+ },
"errorBoundary": {
"message": "Something went wrong displaying this content.",
"tryAgain": "Try again"
@@ -401,7 +407,6 @@
"subtitle": "Configure how \"{{name}}\" is chunked and retrieved.",
"subtitleGeneric": "Configure how this source is chunked and retrieved.",
"save": "Save",
- "readOnly": "You have view-only access to this shared source and cannot change its config.",
"chunkingChangeHint": "You changed a chunking setting. Saving will require re-ingesting this source for it to take effect.",
"prescreenInvalidHint": "Check the prescreen values: candidates to fetch must be at least the number of chunks, and chunks to keep must not exceed candidates to fetch.",
"reingestRequired": "Your changes were saved. The chunking settings you changed only take effect after a re-ingest. Re-ingest now?",
@@ -1794,6 +1799,7 @@
"agents": {
"title": "Agents",
"edit": "Edit",
+ "view": "View",
"card": {
"pin": "Pin agent",
"unpin": "Unpin agent",
@@ -1832,10 +1838,6 @@
"team": "Team",
"shared": "Discovered"
},
- "teamBadge": {
- "editor": "Editor",
- "viewer": "Viewer"
- },
"sections": {
"template": {
"title": "Templates",
@@ -2016,8 +2018,7 @@
"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."
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 43b88552..4d539806 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -10,6 +10,12 @@
"loading": "Cargando...",
"retry": "Reintentar",
"cancel": "Cancelar",
+ "common": {
+ "close": "Cerrar",
+ "viewOnlyNotice": "Puedes ver esto, pero tu rol no puede cambiarlo.",
+ "credentialsLockedNotice": "Solo el propietario puede cambiar las credenciales de esta herramienta.",
+ "savedSecretHint": "Guardado. Déjalo vacío para conservarlo."
+ },
"help": "Asistencia",
"emailUs": "Envíanos un correo",
"documentation": "Documentación",
@@ -396,7 +402,6 @@
"subtitle": "Configura cómo se fragmenta y recupera \"{{name}}\".",
"subtitleGeneric": "Configura cómo se fragmenta y recupera esta fuente.",
"save": "Guardar",
- "readOnly": "Tienes acceso de solo lectura a esta fuente compartida y no puedes cambiar su configuración.",
"chunkingChangeHint": "Cambiaste un ajuste de fragmentación. Al guardar será necesario reindexar esta fuente para que surta efecto.",
"prescreenInvalidHint": "Revisa los valores de preselección: los candidatos a obtener deben ser al menos el número de fragmentos, y los fragmentos a conservar no deben superar los candidatos a obtener.",
"reingestRequired": "Tus cambios se guardaron. Los ajustes de fragmentación que cambiaste solo surten efecto tras reindexar. ¿Reindexar ahora?",
@@ -1778,6 +1783,7 @@
"agents": {
"title": "Agentes",
"edit": "Editar",
+ "view": "Ver",
"card": {
"pin": "Fijar agente",
"unpin": "Dejar de fijar agente",
@@ -1816,10 +1822,6 @@
"team": "Equipo",
"shared": "Compartidos conmigo"
},
- "teamBadge": {
- "editor": "Editor",
- "viewer": "Lector"
- },
"sections": {
"template": {
"title": "Por DocsGPT",
@@ -1998,8 +2000,7 @@
"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."
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index 03f5f466..b2d5ac3a 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -10,6 +10,12 @@
"loading": "読み込み中...",
"retry": "再試行",
"cancel": "キャンセル",
+ "common": {
+ "close": "閉じる",
+ "viewOnlyNotice": "表示はできますが、あなたのロールでは変更できません。",
+ "credentialsLockedNotice": "このツールの認証情報を変更できるのはオーナーだけです。",
+ "savedSecretHint": "保存済みです。空のままにすると保持されます。"
+ },
"help": "ヘルプ",
"emailUs": "メールを送る",
"documentation": "ドキュメント",
@@ -389,7 +395,6 @@
"subtitle": "「{{name}}」のチャンク分割と検索の方法を設定します。",
"subtitleGeneric": "このソースのチャンク分割と検索の方法を設定します。",
"save": "保存",
- "readOnly": "この共有ソースには閲覧のみのアクセス権があるため、設定を変更できません。",
"chunkingChangeHint": "チャンク分割の設定を変更しました。保存後、反映にはこのソースの再インデックスが必要です。",
"prescreenInvalidHint": "プリスクリーニングの値を確認してください。取得する候補数はチャンク数以上、保持するチャンク数は取得する候補数以下である必要があります。",
"reingestRequired": "変更を保存しました。変更したチャンク分割の設定は再インデックス後にのみ反映されます。今すぐ再インデックスしますか?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "エージェント",
"edit": "編集",
+ "view": "表示",
"card": {
"pin": "エージェントをピン留め",
"unpin": "エージェントのピン留めを解除",
@@ -1804,10 +1810,6 @@
"team": "チーム",
"shared": "共有された"
},
- "teamBadge": {
- "editor": "編集者",
- "viewer": "閲覧者"
- },
"sections": {
"template": {
"title": "DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- },
- "readOnly": "このポリシーは表示できますが、あなたのロールでは変更できません。"
+ }
},
"byline": {
"new": "エージェントを設定し、公開するとチャットできます。"
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index 71a053d3..1949563a 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -10,6 +10,12 @@
"loading": "Загрузка...",
"retry": "Повторить",
"cancel": "Отмена",
+ "common": {
+ "close": "Закрыть",
+ "viewOnlyNotice": "Вы можете это просматривать, но ваша роль не позволяет это изменить.",
+ "credentialsLockedNotice": "Только владелец может изменить учётные данные этого инструмента.",
+ "savedSecretHint": "Сохранено. Оставьте поле пустым, чтобы сохранить значение."
+ },
"help": "Помощь",
"emailUs": "Напишите нам",
"documentation": "Документация",
@@ -420,7 +426,6 @@
"subtitle": "Настройте, как «{{name}}» разбивается на фрагменты и извлекается.",
"subtitleGeneric": "Настройте, как этот источник разбивается на фрагменты и извлекается.",
"save": "Сохранить",
- "readOnly": "У вас доступ только для просмотра этого общего источника, вы не можете изменять его настройки.",
"chunkingChangeHint": "Вы изменили настройку разбиения на фрагменты. После сохранения потребуется переиндексация источника, чтобы изменения вступили в силу.",
"prescreenInvalidHint": "Проверьте значения предварительного отбора: число кандидатов должно быть не меньше числа фрагментов, а число оставляемых фрагментов не должно превышать число кандидатов.",
"reingestRequired": "Изменения сохранены. Изменённые настройки разбиения вступят в силу только после переиндексации. Переиндексировать сейчас?",
@@ -1840,6 +1845,7 @@
"agents": {
"title": "Агенты",
"edit": "Редактировать",
+ "view": "Просмотр",
"card": {
"pin": "Закрепить агента",
"unpin": "Открепить агента",
@@ -1878,10 +1884,6 @@
"team": "Команда",
"shared": "Поделились со мной"
},
- "teamBadge": {
- "editor": "Редактор",
- "viewer": "Читатель"
- },
"sections": {
"template": {
"title": "От DocsGPT",
@@ -2062,8 +2064,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- },
- "readOnly": "Вы видите эту политику, но ваша роль не позволяет её изменить."
+ }
},
"byline": {
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 3f10dc96..f89e7372 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -10,6 +10,12 @@
"loading": "載入中...",
"retry": "重試",
"cancel": "取消",
+ "common": {
+ "close": "關閉",
+ "viewOnlyNotice": "你可以檢視此內容,但你的角色無法變更它。",
+ "credentialsLockedNotice": "只有擁有者可以變更此工具的憑證。",
+ "savedSecretHint": "已儲存。留空即可保留。"
+ },
"help": "幫助",
"emailUs": "給我們發電郵",
"documentation": "文件",
@@ -389,7 +395,6 @@
"subtitle": "設定「{{name}}」的分塊與檢索方式。",
"subtitleGeneric": "設定此來源的分塊與檢索方式。",
"save": "儲存",
- "readOnly": "您對此共享來源僅有檢視權限,無法變更其設定。",
"chunkingChangeHint": "您變更了分塊設定。儲存後需重新索引此來源才會生效。",
"prescreenInvalidHint": "請檢查預篩選數值:擷取候選數不得少於區塊數,且保留區塊數不得超過擷取候選數。",
"reingestRequired": "變更已儲存。您變更的分塊設定需重新索引後才會生效。要立即重新索引嗎?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "代理",
"edit": "編輯",
+ "view": "檢視",
"card": {
"pin": "釘選代理",
"unpin": "取消釘選代理",
@@ -1804,10 +1810,6 @@
"team": "團隊",
"shared": "與我共享"
},
- "teamBadge": {
- "editor": "編輯者",
- "viewer": "檢視者"
- },
"sections": {
"template": {
"title": "由DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- },
- "readOnly": "你可以檢視此政策,但你的角色無法變更它。"
+ }
},
"byline": {
"new": "設定好代理後發佈,即可與它對話。"
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 1220c12c..8163533b 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -10,6 +10,12 @@
"loading": "加载中...",
"retry": "重试",
"cancel": "取消",
+ "common": {
+ "close": "关闭",
+ "viewOnlyNotice": "你可以查看此内容,但你的角色无法更改它。",
+ "credentialsLockedNotice": "只有所有者可以更改此工具的凭据。",
+ "savedSecretHint": "已保存。留空即可保留。"
+ },
"help": "帮助",
"emailUs": "给我们发邮件",
"documentation": "文档",
@@ -389,7 +395,6 @@
"subtitle": "配置“{{name}}”的分块与检索方式。",
"subtitleGeneric": "配置此来源的分块与检索方式。",
"save": "保存",
- "readOnly": "您对此共享来源仅有查看权限,无法更改其配置。",
"chunkingChangeHint": "您更改了分块设置。保存后需要重新索引此来源才能生效。",
"prescreenInvalidHint": "请检查预筛选参数:获取的候选数量至少应等于文本块数量,且保留的文本块数不得超过获取的候选数量。",
"reingestRequired": "您的更改已保存。修改的分块设置需重新索引后才会生效。立即重新索引?",
@@ -1766,6 +1771,7 @@
"agents": {
"title": "代理",
"edit": "编辑",
+ "view": "查看",
"card": {
"pin": "固定代理",
"unpin": "取消固定代理",
@@ -1804,10 +1810,6 @@
"team": "团队",
"shared": "与我共享"
},
- "teamBadge": {
- "editor": "编辑者",
- "viewer": "查看者"
- },
"sections": {
"template": {
"title": "由DocsGPT提供",
@@ -1985,8 +1987,7 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- },
- "readOnly": "你可以查看此策略,但你的角色无法更改它。"
+ }
},
"byline": {
"new": "设置好智能体后发布,即可与它对话。"
diff --git a/frontend/src/modals/AgentDetailsModal.test.tsx b/frontend/src/modals/AgentDetailsModal.test.tsx
index c070e0a6..f4a96b1d 100644
--- a/frontend/src/modals/AgentDetailsModal.test.tsx
+++ b/frontend/src/modals/AgentDetailsModal.test.tsx
@@ -105,6 +105,9 @@ describe('AgentDetailsModal', () => {
await act(async () => generateButtons()[0].click());
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Not allowed');
+ // Destructive Alerts lead with CircleAlert, like every other one.
+ expect(alert?.querySelector('svg.lucide-circle-alert')).not.toBeNull();
+ expect(alert?.querySelector('svg.lucide-circle-x')).toBeNull();
});
it('shows a failed webhook in an alert with a fallback message', async () => {
diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx
index 5f5322e3..6a150a4a 100644
--- a/frontend/src/modals/AgentDetailsModal.tsx
+++ b/frontend/src/modals/AgentDetailsModal.tsx
@@ -1,5 +1,5 @@
import { envVar } from '@/env';
-import { CircleX, ExternalLink } from 'lucide-react';
+import { CircleAlert, ExternalLink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -140,7 +140,7 @@ export default function AgentDetailsModal({
{error && (
-
+
{error}
)}
diff --git a/frontend/src/modals/MCPServerModal.test.tsx b/frontend/src/modals/MCPServerModal.test.tsx
index 8f49dcf6..d3a98311 100644
--- a/frontend/src/modals/MCPServerModal.test.tsx
+++ b/frontend/src/modals/MCPServerModal.test.tsx
@@ -108,12 +108,23 @@ describe('MCPServerModal', () => {
expect(text()).toContain(
'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}',
);
- const info = Array.from(
- document.body.querySelectorAll
('[role="alert"]'),
+ // Informative, not announced: a quiet default Alert with role="note".
+ const note = Array.from(
+ document.body.querySelectorAll('[data-slot="alert"]'),
).find((a) =>
a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'),
);
- expect(info?.dataset.variant ?? info?.className).toMatch(/info/);
+ expect(note?.getAttribute('role')).toBe('note');
+ expect(note?.dataset.variant).toBe('default');
+ });
+
+ it('masks the API key and bearer token fields', async () => {
+ await render();
+ const key = document.body.querySelector(
+ 'input[placeholder="settings.tools.mcp.placeholders.apiKey"]',
+ );
+ expect(key?.type).toBe('password');
+ expect(key?.value).toBe('');
});
it('falls back to "a teammate" without an owner label', async () => {
diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx
index 5a377591..975ec1c0 100644
--- a/frontend/src/modals/MCPServerModal.tsx
+++ b/frontend/src/modals/MCPServerModal.tsx
@@ -1,4 +1,4 @@
-import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react';
+import { CircleAlert, CircleCheck, Lock, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -509,7 +509,8 @@ export default function MCPServerModal({
}
>
handleInputChange('api_key', e.target.value)}
placeholder={t('settings.tools.mcp.placeholders.apiKey')}
@@ -538,7 +539,8 @@ export default function MCPServerModal({
}
>
handleInputChange('bearer_token', e.target.value)
@@ -656,8 +658,8 @@ export default function MCPServerModal({
>
{isShared && (
-
-
+
+
{t('settings.tools.mcp.sharedCredentialsNotice')}
{oauthOwnerOnly &&
diff --git a/frontend/src/preferences/PromptsModal.test.tsx b/frontend/src/preferences/PromptsModal.test.tsx
index f12b3d3f..c461bc94 100644
--- a/frontend/src/preferences/PromptsModal.test.tsx
+++ b/frontend/src/preferences/PromptsModal.test.tsx
@@ -166,5 +166,48 @@ describe('PromptsModal', () => {
(b) => b.textContent,
);
expect(labels).not.toContain('modals.prompts.save');
+ // A shared prompt gets the shared view-only notice, not the built-in copy.
+ expect(document.body.textContent).toContain('common.viewOnlyNotice');
+ expect(document.body.textContent).not.toContain(
+ 'modals.prompts.viewDescription',
+ );
+ expect(
+ document.body.querySelector('[data-slot="alert"]')?.getAttribute('role'),
+ ).toBe('note');
+ expect(labels).toContain('common.close');
+ expect(labels).not.toContain('modals.prompts.cancel');
+ });
+
+ it('keeps the built-in copy for a built-in prompt', async () => {
+ await act(async () => {
+ root.render(
+ undefined}
+ type="EDIT"
+ newPromptName=""
+ setNewPromptName={() => undefined}
+ newPromptContent=""
+ setNewPromptContent={() => undefined}
+ editPromptName="Default"
+ setEditPromptName={() => undefined}
+ editPromptContent="You are a helpful assistant."
+ setEditPromptContent={() => undefined}
+ currentPromptEdit={{ name: 'Default', id: 'd1', type: 'public' }}
+ handleEditPrompt={() => undefined}
+ onDuplicate={() => undefined}
+ />,
+ );
+ });
+ expect(document.body.textContent).toContain(
+ 'modals.prompts.viewDescription',
+ );
+ expect(document.body.textContent).not.toContain('common.viewOnlyNotice');
+ const labels = Array.from(document.body.querySelectorAll('button')).map(
+ (b) => b.textContent,
+ );
+ expect(labels).toContain('modals.prompts.duplicate');
+ expect(labels).toContain('common.close');
});
});
diff --git a/frontend/src/preferences/PromptsModal.tsx b/frontend/src/preferences/PromptsModal.tsx
index 2f734a60..2ddb5395 100644
--- a/frontend/src/preferences/PromptsModal.tsx
+++ b/frontend/src/preferences/PromptsModal.tsx
@@ -4,6 +4,7 @@ import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { FormField } from '../components/ui/form-field';
+import ViewOnlyNotice from '../components/ViewOnlyNotice';
import { Link } from 'react-router-dom';
import React from 'react';
@@ -377,12 +378,15 @@ function EditPrompt({
editPromptContent,
setEditPromptContent,
isReadOnly,
+ showViewOnlyNotice = false,
}: {
editPromptName: string;
setEditPromptName: (name: string) => void;
editPromptContent: string;
setEditPromptContent: (content: string) => void;
isReadOnly: boolean;
+ /** A shared prompt the caller's role can't change (not a built-in one). */
+ showViewOnlyNotice?: boolean;
}) {
const { t } = useTranslation();
const systemVariableOptions = React.useMemo(
@@ -394,6 +398,7 @@ function EditPrompt({
return (
+ {showViewOnlyNotice && }
setModalState('INACTIVE');
let view;
let title: string;
- let description: string;
+ let description: string | undefined;
if (type === 'ADD') {
title = duplicateSourceName
@@ -546,11 +554,11 @@ export default function PromptsModal({
title = t(
isReadOnly ? 'modals.prompts.viewPrompt' : 'modals.prompts.editPrompt',
);
- description = t(
- isReadOnly
- ? 'modals.prompts.viewDescription'
- : 'modals.prompts.editDescription',
- );
+ description = isBuiltIn
+ ? t('modals.prompts.viewDescription')
+ : isReadOnly
+ ? undefined
+ : t('modals.prompts.editDescription');
view = (
);
}
@@ -610,19 +619,19 @@ export default function PromptsModal({
footer = (
);
} else {
- // A public prompt with nothing to do but close: the link and a lone
- // Cancel.
+ // A prompt to view with nothing to do but close: the link and a lone
+ // Close.
footer = (
);
diff --git a/frontend/src/settings/SourceConfigModal.test.tsx b/frontend/src/settings/SourceConfigModal.test.tsx
index 02d73e5d..1772d27f 100644
--- a/frontend/src/settings/SourceConfigModal.test.tsx
+++ b/frontend/src/settings/SourceConfigModal.test.tsx
@@ -58,8 +58,11 @@ describe('SourceConfigModal access', () => {
};
const readOnlyNotice = () =>
- document.body.textContent?.includes(
- 'settings.sources.configModal.readOnly',
+ document.body.textContent?.includes('common.viewOnlyNotice');
+
+ const buttonLabels = () =>
+ Array.from(document.body.querySelectorAll('button')).map(
+ (b) => b.textContent,
);
it('a viewer with view_config only sees the read-only notice', async () => {
@@ -72,6 +75,12 @@ describe('SourceConfigModal access', () => {
}),
);
expect(readOnlyNotice()).toBe(true);
+ const note = document.body.querySelector('[data-slot="alert"]');
+ expect(note?.getAttribute('role')).toBe('note');
+ // View-only: no Save, and Cancel becomes a lone Close.
+ expect(buttonLabels()).not.toContain('settings.sources.configModal.save');
+ expect(buttonLabels()).not.toContain('cancel');
+ expect(buttonLabels()).toContain('common.close');
});
it('an editor can edit (no read-only notice)', async () => {
@@ -84,6 +93,8 @@ describe('SourceConfigModal access', () => {
}),
);
expect(readOnlyNotice()).toBe(false);
+ expect(buttonLabels()).toContain('settings.sources.configModal.save');
+ expect(buttonLabels()).toContain('cancel');
});
it('follows allowed_actions over the legacy team_access', async () => {
diff --git a/frontend/src/settings/SourceConfigModal.tsx b/frontend/src/settings/SourceConfigModal.tsx
index 4266a35a..9a27f2ce 100644
--- a/frontend/src/settings/SourceConfigModal.tsx
+++ b/frontend/src/settings/SourceConfigModal.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
+import ViewOnlyNotice from '../components/ViewOnlyNotice';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Modal, ModalActions } from '../components/ui/modal';
import { ActiveState, Doc } from '../models/misc';
@@ -148,6 +149,8 @@ export default function SourceConfigModal({
submitLabel={t('settings.sources.reingest')}
onSubmit={handleConfirmReingest}
/>
+ ) : isReadOnly ? (
+
) : (
);
@@ -189,11 +192,7 @@ export default function SourceConfigModal({
) : (
- {isReadOnly && (
-
- {t('settings.sources.configModal.readOnly')}
-
- )}
+ {isReadOnly &&
}
{
).map((el) => el.textContent);
};
+ const menuIcon = (label: string) =>
+ Array.from(document.querySelectorAll('[role="menuitem"]'))
+ .find((el) => el.textContent === label)
+ ?.querySelector('svg')
+ ?.getAttribute('class') ?? '';
+
const clickItem = async (label: string) => {
const item = Array.from(
document.querySelectorAll('[role="menuitem"]'),
@@ -177,6 +183,7 @@ describe('Sources access', () => {
'settings.sources.testRetrieval.action',
'settings.sources.wiki.convert.action',
]);
+ expect(menuIcon('settings.sources.editConfig')).not.toContain('lucide-eye');
});
it('editors_can_share / editors_can_delete widen the editor menu', async () => {
@@ -206,6 +213,8 @@ describe('Sources access', () => {
'settings.sources.viewConfig',
'settings.sources.testRetrieval.action',
]);
+ // View swaps the config item's icon for Eye, like every View label.
+ expect(menuIcon('settings.sources.viewConfig')).toContain('lucide-eye');
});
it('viewer without view_config: no config item', async () => {
diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx
index 22f748a7..cdbd0773 100644
--- a/frontend/src/settings/Sources.tsx
+++ b/frontend/src/settings/Sources.tsx
@@ -19,6 +19,7 @@ import userService from '../api/services/userService';
import modelService from '../api/services/modelService';
import PageToolbar from '../components/PageToolbar';
+import RoleBadge from '../components/RoleBadge';
import SearchInput from '../components/SearchInput';
import SkeletonLoader from '../components/SkeletonLoader';
import { Badge } from '../components/ui/badge';
@@ -46,7 +47,7 @@ import {
selectUploadTasks,
updateUploadTask,
} from '../upload/uploadSlice';
-import { can, roleOf } from '../utils/accessUtils';
+import { can } from '../utils/accessUtils';
import { formatDate } from '../utils/dateTimeUtils';
import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
@@ -412,7 +413,7 @@ export default function Sources({
// Editors edit the config; a viewer may read it (view_config).
if (document.id && !isWiki && (canEdit || can(document, 'view_config'))) {
actions.push({
- icon: SlidersHorizontal,
+ icon: canEdit ? SlidersHorizontal : Eye,
label: canEdit
? t('settings.sources.editConfig')
: t('settings.sources.viewConfig'),
@@ -693,14 +694,7 @@ export default function Sources({
- {roleOf(document) !== 'owner' && (
-
-
- {roleOf(document) === 'editor'
- ? t('teamAccess.editor')
- : t('teamAccess.viewer')}
-
- )}
+
{document.ingestStatus === 'failed' && (
{t('settings.sources.ingestFailed')}
@@ -728,10 +722,7 @@ export default function Sources({
: null;
return (
-
+
{isBuilding
? pct !== null
? t(
diff --git a/frontend/src/settings/Teams.test.tsx b/frontend/src/settings/Teams.test.tsx
index e0c1ebe1..b9d4ed2c 100644
--- a/frontend/src/settings/Teams.test.tsx
+++ b/frontend/src/settings/Teams.test.tsx
@@ -81,6 +81,12 @@ vi.mock('../api/services/teamsService', () => ({
},
}));
+// Mark formatted counts so a raw number in the UI shows up in a test.
+vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({
+ ...(await importOriginal()),
+ formatCount: (value: number) => `#${value}`,
+}));
+
import Teams from './Teams';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -217,7 +223,7 @@ describe('Teams page', () => {
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('Key Accounts');
expect(rows[0].textContent).toContain(
- 'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1)',
+ '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)',
@@ -225,12 +231,58 @@ describe('Teams page', () => {
// 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',
+ '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',
]);
+ expect(body().textContent).toContain('settings.teams.sharedResources · #2');
+ });
+
+ it('shows the no-results line when the search matches nothing', async () => {
+ listGrants.mockResolvedValue({
+ team_role: 'team_admin',
+ grants: [grant()],
+ });
+ await render();
+ const search = body().querySelector(
+ 'input[aria-label="settings.teams.sharedList.search"]',
+ )!;
+ act(() => {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )!.set!;
+ setter.call(search, 'nothing like this');
+ search.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ const empty = Array.from(
+ body().querySelectorAll('[data-slot="empty-state"]'),
+ ).find((e) =>
+ e.textContent?.includes('settings.teams.sharedList.noMatches'),
+ );
+ expect(empty?.getAttribute('data-size')).toBe('xs');
+ expect(empty?.querySelector('img')).toBeNull();
+ });
+
+ it('keeps the drawer header fixed above one scrolling body', async () => {
+ listGrants.mockResolvedValue({
+ team_role: 'team_admin',
+ grants: [grant()],
+ });
+ await render();
+ await openDrawer();
+ const sheet = body().querySelector('[data-slot="sheet-content"]')!;
+ const title = sheet.querySelector('[data-slot="sheet-title"]')!;
+ expect(title.className).toContain('wrap-break-word');
+ expect(title.className).not.toContain('truncate');
+ const scrollers = sheet.querySelectorAll('.overflow-y-auto');
+ expect(scrollers).toHaveLength(1);
+ // The title and the Open button stay put; the details scroll.
+ expect(scrollers[0].contains(title)).toBe(false);
+ expect(scrollers[0].textContent).toContain('settings.teams.drawer.owner');
+ expect(scrollers[0].className).toContain('px-6 py-6');
});
it('shows role selects, remove and Manage sharing to a caller who can share', async () => {
diff --git a/frontend/src/settings/Teams.tsx b/frontend/src/settings/Teams.tsx
index 37a45ff6..b897d190 100644
--- a/frontend/src/settings/Teams.tsx
+++ b/frontend/src/settings/Teams.tsx
@@ -74,6 +74,7 @@ import {
} from '../components/ui/sheet';
import { Textarea } from '../components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
+import { cn } from '../lib/utils';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { showActionToast } from '../notifications/actionToastSlice';
@@ -101,7 +102,7 @@ import {
Team,
} from '../teams/teamsSlice';
import { can } from '../utils/accessUtils';
-import { formatDateOnly } from '../utils/dateTimeUtils';
+import { formatCount, formatDateOnly } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
type Member = {
@@ -696,7 +697,7 @@ export default function Teams() {
? t('settings.teams.sharedList.badgeWithEditors', {
interpolation: { escapeValue: false },
level,
- count: memberEditors,
+ count: formatCount(memberEditors),
})
: level;
}
@@ -866,13 +867,13 @@ export default function Teams() {
(team.member_count ?? 0) === 1
? 'settings.teams.memberCountOne'
: 'settings.teams.memberCountOther',
- { count: team.member_count ?? 0 },
+ { count: formatCount(team.member_count ?? 0) },
)}
·
{t('settings.teams.sharedCount', {
- count: team.shared_count ?? 0,
+ count: formatCount(team.shared_count ?? 0),
})}
@@ -945,7 +946,7 @@ export default function Teams() {
{sharedResources.length === 0 ? (
@@ -1047,7 +1048,7 @@ export default function Teams() {
(value) => (
{t(`settings.teams.sharedList.filter.${value}`)}{' '}
- {resourceCounts[value]}
+ {formatCount(resourceCounts[value])}
),
)}
@@ -1063,7 +1064,8 @@ export default function Teams() {
{visibleResources.length === 0 ? (
) : (
@@ -1133,62 +1135,64 @@ export default function Teams() {
className="p-0"
closeLabel={t('settings.teams.drawer.close')}
>
-
- {/* pr-12 keeps the header clear of the close X. */}
-
-
- {resourceTypeIcon(openResource.type)}
-
-
-
- {resourceName(openResource)}
-
-
- {t('settings.teams.drawer.subtitle', {
- interpolation: { escapeValue: false },
- type: resourceTypeLabel(openResource.type),
- owner: ownerLabel(openResource),
- })}
-
+
+ {/* A fixed header: pr-12 keeps it clear of the close X. */}
+
+
+
+ {resourceTypeIcon(openResource.type)}
+
+
+
+ {resourceName(openResource)}
+
+
+ {t('settings.teams.drawer.subtitle', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ owner: ownerLabel(openResource),
+ })}
+
+
-
-
-
{
- const path = openAssetPath(openResource);
- closeDrawer();
- navigate(path);
- }}
- >
-
- {t('settings.teams.drawer.open', {
- interpolation: { escapeValue: false },
- type: resourceTypeLabel(openResource.type),
- })}
-
- {callerCanShare && (
+
{
- setDrawerOpen(false);
- setShareTarget(openResource);
+ const path = openAssetPath(openResource);
+ closeDrawer();
+ navigate(path);
}}
>
-
- {t('settings.teams.drawer.manageSharing')}
+
+ {t('settings.teams.drawer.open', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ })}
- )}
+ {callerCanShare && (
+
{
+ setDrawerOpen(false);
+ setShareTarget(openResource);
+ }}
+ >
+
+ {t('settings.teams.drawer.manageSharing')}
+
+ )}
+
-
+
{ownerLabel(openResource)}
@@ -1337,9 +1341,9 @@ export default function Teams() {
/>
)}
{line.text}
diff --git a/frontend/src/settings/ToolConfig.test.tsx b/frontend/src/settings/ToolConfig.test.tsx
index f747f2c9..c91f233c 100644
--- a/frontend/src/settings/ToolConfig.test.tsx
+++ b/frontend/src/settings/ToolConfig.test.tsx
@@ -426,11 +426,17 @@ describe('ToolConfig', () => {
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();
+ const note = container.querySelector('[data-slot="alert"]');
+ expect(note?.getAttribute('role')).toBe('note');
+ expect(note?.textContent).toBe('common.viewOnlyNotice');
expect(nameInput().disabled).toBe(true);
- const secret = Array.from(
- container.querySelectorAll('input'),
- ).find((i) => i.placeholder === '••••••••');
+ const secret = container.querySelector(
+ 'input[type="password"]',
+ );
expect(secret && disabled(secret)).toBe(true);
+ expect(secret?.value).toBe('');
+ expect(secret?.placeholder).not.toContain('•');
+ expect(container.textContent).toContain('common.savedSecretHint');
container
.querySelectorAll('[role="switch"]')
.forEach((sw) => expect(disabled(sw)).toBe(true));
@@ -461,13 +467,33 @@ describe('ToolConfig', () => {
it('lets an editor without edit_credentials rename but not touch credentials', async () => {
await render({ ...configTool, ...editorNoCreds } as UserToolType);
expect(nameInput().disabled).toBe(false);
+ // Not a view-only form: the editor can still save a rename.
+ expect(container.textContent).not.toContain('common.viewOnlyNotice');
const authInputs = Array.from(
container.querySelectorAll('input'),
).filter((i) => i !== nameInput() && !i.closest('table'));
- const credential = authInputs.find((i) => i.placeholder === '••••••••');
+ const credential = authInputs.find((i) => i.type === 'password');
expect(credential && disabled(credential)).toBe(true);
});
+ it('says why the credentials are locked for an editor without edit_credentials', async () => {
+ await render({ ...configTool, ...editorNoCreds } as UserToolType);
+ const note = container.querySelector('[data-slot="alert"]');
+ expect(note?.getAttribute('role')).toBe('note');
+ expect(note?.textContent).toBe('common.credentialsLockedNotice');
+ });
+
+ it('shows no credentials note to a role that may change them', async () => {
+ await render({
+ ...configTool,
+ access: 'editor',
+ allowed_actions: ['edit', 'edit_credentials', 'use'],
+ } as UserToolType);
+ expect(container.textContent).not.toContain(
+ 'common.credentialsLockedNotice',
+ );
+ });
+
it("disables an API tool's URL and header values without edit_credentials", async () => {
await render({ ...apiTool, ...editorNoCreds } as APIToolType);
await expandFirstAction();
diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx
index b8d8d628..fc7598d2 100644
--- a/frontend/src/settings/ToolConfig.tsx
+++ b/frontend/src/settings/ToolConfig.tsx
@@ -5,6 +5,7 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ConfigFields from '../components/ConfigFields';
+import ViewOnlyNotice from '../components/ViewOnlyNotice';
import SearchInput from '../components/SearchInput';
import { Alert, AlertDescription } from '../components/ui/alert';
import {
@@ -395,6 +396,10 @@ export default function ToolConfig({
)}
+ {readOnly &&
}
+ {!readOnly && !canEditCredentials && (
+
+ )}
{saveError && (
{saveError}
diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx
index caf80038..141d8b42 100644
--- a/frontend/src/settings/Tools.test.tsx
+++ b/frontend/src/settings/Tools.test.tsx
@@ -229,13 +229,25 @@ describe('Tools', () => {
);
});
- it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => {
+ // The tool can't be in the caller's chats at all (the composer picker
+ // hides it too), so there is no state to show: no switch, no bare grey one.
+ it('hides the switch and 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',
- );
+ expect(card('vw').querySelector('[role="switch"]')).toBeNull();
+ expect(card('vw').textContent).not.toContain('settings.tools.inMyChats');
+ });
+
+ it('shows the role on a shared tile as a neutral Users Badge', async () => {
+ await render([ownTool, editorTool, viewerTool]);
+ const badge = (id: string) =>
+ card(id).querySelector('[data-testid="role-badge"]');
+ expect(badge('own')).toBeNull();
+ expect(badge('ed')?.dataset.variant).toBe('neutral');
+ expect(badge('ed')?.textContent).toBe('teamAccess.editor');
+ expect(badge('vw')?.textContent).toBe('teamAccess.viewer');
+ expect(
+ badge('ed')?.querySelector('svg')?.getAttribute('class'),
+ ).not.toContain('size-3');
});
it('reverts the switch and shows an error toast when the update fails', async () => {
diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx
index 4f3d62fa..6e8a71b6 100644
--- a/frontend/src/settings/Tools.tsx
+++ b/frontend/src/settings/Tools.tsx
@@ -7,6 +7,7 @@ import devicesService from '../api/services/devicesService';
import userService from '../api/services/userService';
import PageToolbar from '../components/PageToolbar';
import SearchInput from '../components/SearchInput';
+import RoleBadge from '../components/RoleBadge';
import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Badge } from '../components/ui/badge';
@@ -24,7 +25,7 @@ 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 { can, isOwner, roleOf } from '../utils/accessUtils';
import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
@@ -116,7 +117,7 @@ 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),
+ access: roleOf(tool),
owner_label: tool.owner_label ?? null,
});
setReconnectModalState('ACTIVE');
@@ -395,14 +396,7 @@ export default function Tools() {
)}
)}
- {tool.ownership === 'team' && (
-
-
- {tool.team_access === 'editor'
- ? t('teamAccess.editor')
- : t('teamAccess.viewer')}
-
- )}
+
-
-
- {t('settings.tools.inMyChats')}
-
-
- updateToolStatus(tool.id, checked)
- }
- disabled={!canAddToolToOwn(tool)}
- id={`toolToggle-${index}`}
- aria-label={t('settings.tools.useInMyChatsAria', {
- interpolation: { escapeValue: false },
- toolName: tool.customName || tool.displayName,
- })}
- />
-
+ {/* A shared tool without use_in_own can't be in the
+ caller's chats at all, so there is no switch. */}
+ {canAddToolToOwn(tool) && (
+
+
+ {t('settings.tools.inMyChats')}
+
+
+ updateToolStatus(tool.id, checked)
+ }
+ id={`toolToggle-${index}`}
+ aria-label={t('settings.tools.useInMyChatsAria', {
+ interpolation: { escapeValue: false },
+ toolName: tool.customName || tool.displayName,
+ })}
+ />
+
+ )}
))}
diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx
index aa5117ee..ef6d30ac 100644
--- a/frontend/src/teams/ShareToTeamModal.test.tsx
+++ b/frontend/src/teams/ShareToTeamModal.test.tsx
@@ -47,6 +47,12 @@ vi.mock('../api/services/teamsService', () => ({
},
}));
+// Mark formatted counts so a raw number in the UI shows up in a test.
+vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ formatCount: (value: number) => `#${value}`,
+}));
+
import ShareToTeamModal from './ShareToTeamModal';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -131,7 +137,11 @@ describe('ShareToTeamModal', () => {
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');
+ // An inline disclosure (no Card around it to draw section-toggle's
+ // ring), so the button shows its own keyboard focus.
+ expect(toggle?.getAttribute('data-variant')).toBe('link');
+ expect(toggle?.className).toContain('focus-visible:ring-3');
+ expect(toggle?.className).not.toContain('focus-visible:ring-0');
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(0);
act(() => toggle!.click());
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4);
@@ -249,8 +259,11 @@ describe('ShareToTeamModal', () => {
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)');
+ expect(text()).toContain('settings.teams.share.andMore(count=#5)');
const showAll = buttonByText('settings.teams.share.showAll');
+ expect(showAll?.textContent).toContain(
+ 'settings.teams.share.showAll(count=#8)',
+ );
expect(showAll?.getAttribute('data-variant')).toBe('link');
expect(body().querySelector('.max-h-72')).toBeNull();
});
@@ -260,11 +273,21 @@ describe('ShareToTeamModal', () => {
await render();
act(() => buttonByText('settings.teams.share.showAll')!.click());
expect(text()).toContain(
- 'settings.teams.share.allSummary(name=QBR Report Builder,teams=2,people=6)',
+ '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);
+ expect(
+ Array.from(body().querySelectorAll('[role="radio"]')).map(
+ (p) => p.textContent,
+ ),
+ ).toEqual([
+ 'settings.teams.share.filter.all #8',
+ 'settings.teams.share.filter.teams #2',
+ 'settings.teams.share.filter.people #6',
+ 'settings.teams.share.filter.editors #2',
+ ]);
const teamsPill = Array.from(
body().querySelectorAll('[role="radio"]'),
@@ -294,6 +317,20 @@ describe('ShareToTeamModal', () => {
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toContain('user-6');
+ // No match: the "no results" EmptyState line, not a bare paragraph.
+ act(() => {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )!.set!;
+ setter.call(search, 'nobody-here');
+ search!.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ const empty = body().querySelector('[data-slot="empty-state"]');
+ expect(empty?.getAttribute('data-size')).toBe('xs');
+ expect(empty?.querySelector('img')).toBeNull();
+ expect(empty?.textContent).toContain('settings.teams.share.noMatches');
+
act(() => buttonByText('settings.teams.share.back')!.click());
expect(buttonByText('settings.teams.share.showAll')).toBeDefined();
});
diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx
index dc464a59..ac875b3c 100644
--- a/frontend/src/teams/ShareToTeamModal.tsx
+++ b/frontend/src/teams/ShareToTeamModal.tsx
@@ -21,6 +21,7 @@ import SearchInput from '../components/SearchInput';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
+import { EmptyState } from '../components/ui/empty-state';
import {
Command,
CommandEmpty,
@@ -52,6 +53,7 @@ import { cn } from '../lib/utils';
import { selectToken } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import { can } from '../utils/accessUtils';
+import { formatCount } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
import {
anyChanged,
@@ -688,8 +690,8 @@ export default function ShareToTeamModal({
{t('settings.teams.share.allSummary', {
interpolation: { escapeValue: false },
name: resourceName ?? '',
- teams: teamGrantCount,
- people: personGrantCount,
+ teams: formatCount(teamGrantCount),
+ people: formatCount(personGrantCount),
})}
@@ -712,7 +714,8 @@ export default function ShareToTeamModal({
>
{filterOptions.map((option) => (
- {t(`settings.teams.share.filter.${option.value}`)} {option.count}
+ {t(`settings.teams.share.filter.${option.value}`)}{' '}
+ {formatCount(option.count)}
))}
@@ -722,36 +725,36 @@ export default function ShareToTeamModal({
{filteredShares.map(renderShareRow)}
{filteredShares.length === 0 && (
-
- {t('settings.teams.share.noMatches')}
-
+
)}
);
const accessSettings = canManageSettings && (
-
- setSettingsOpen((open) => !open)}
- >
-
-
- {t('settings.teams.accessSettings.title')}
-
-
-
+ {/* An inline disclosure: no Card around it draws section-toggle's
+ ring, so the link button shows its own focus. */}
+ setSettingsOpen((open) => !open)}
+ >
+
+ {t('settings.teams.accessSettings.title')}
+
{settingsOpen && (
{settings.map((setting) => {
@@ -893,7 +896,7 @@ export default function ShareToTeamModal({
onValueChange={(value) => setAccessLevel(value as AccessLevel)}
>
@@ -931,7 +934,7 @@ export default function ShareToTeamModal({
onClick={openAllStep}
>
{t('settings.teams.share.showAll', {
- count: shares.length,
+ count: formatCount(shares.length),
})}
@@ -945,7 +948,7 @@ export default function ShareToTeamModal({
{isLongList && (
{t('settings.teams.share.andMore', {
- count: shares.length - previewShares.length,
+ count: formatCount(shares.length - previewShares.length),
})}
)}
diff --git a/tests/api/user/sources/test_source_roles.py b/tests/api/user/sources/test_source_roles.py
index c4a2282a..ed5ec259 100644
--- a/tests/api/user/sources/test_source_roles.py
+++ b/tests/api/user/sources/test_source_roles.py
@@ -134,10 +134,21 @@ class TestListPayload:
v = next(r for r in viewer.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
- assert "config" not in v
+ # Only the behaviour selector survives: the UI needs it to pick the view.
+ assert v["config"] == {"kind": "classic"}
assert v["allowed_actions"] == ["use"]
- assert "config" not in vp
- assert "config" in e
+ assert vp["config"] == {"kind": "classic"}
+ assert "retrieval" in e["config"]
+
+ def test_viewer_keeps_graphrag_kind_when_config_hidden(self, app, pg_conn):
+ from docsgpt.api.user.sources.routes import CombinedJson
+
+ sid = _shared_source(pg_conn, config={"kind": "graphrag"})
+ _set(pg_conn, sid, viewers_can_see_config=False)
+ with _patch_db(pg_conn, ROUTES):
+ viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
+ v = next(r for r in viewer.json if r["id"] == sid)
+ assert v["config"] == {"kind": "graphrag"}
def test_paginated_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import PaginatedSources
From 7530e54aecb87a9a48313a3cc215714d7c9dd1b0 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 11:43:43 +0400
Subject: [PATCH 4/9] Shared resource use
---
docsgpt/agents/headless_runner.py | 11 +-
docsgpt/agents/tool_executor.py | 14 +
docsgpt/agents/workflow_agent.py | 4 +
docsgpt/agents/workflows/node_agent.py | 3 +
docsgpt/agents/workflows/workflow_engine.py | 42 +-
.../versions/0039_resource_sponsors.py | 40 ++
.../api/answer/services/stream_processor.py | 41 +-
docsgpt/api/user/agents/routes.py | 20 +
docsgpt/api/user/resource_access.py | 217 +++++++++
docsgpt/api/user/workflows/routes.py | 54 ++-
docsgpt/services/search_service.py | 11 +-
docsgpt/storage/db/models.py | 5 +
docsgpt/storage/db/repositories/agents.py | 10 +-
docsgpt/storage/db/repositories/workflows.py | 10 +-
frontend/DESIGN.md | 6 +-
frontend/src/agents/NewAgent.tsx | 27 +-
.../SponsoredResourcesNotice.test.tsx | 103 ++++
.../components/SponsoredResourcesNotice.tsx | 91 ++++
frontend/src/agents/types/index.ts | 14 +
frontend/src/locale/de.json | 22 +-
frontend/src/locale/en.json | 22 +-
frontend/src/locale/es.json | 22 +-
frontend/src/locale/jp.json | 31 +-
frontend/src/locale/ru.json | 30 +-
frontend/src/locale/zh-TW.json | 31 +-
frontend/src/locale/zh.json | 31 +-
frontend/src/settings/Teams.test.tsx | 2 +-
frontend/src/settings/Teams.tsx | 16 +-
frontend/src/teams/ShareToTeamModal.test.tsx | 6 +-
frontend/src/teams/ShareToTeamModal.tsx | 5 +-
tests/api/test_agent_team_sharing.py | 3 +
tests/api/user/test_resource_sponsors.py | 459 ++++++++++++++++++
tests/services/test_search_service.py | 18 +-
tests/tracing/test_entry_points.py | 2 +-
34 files changed, 1322 insertions(+), 101 deletions(-)
create mode 100644 docsgpt/alembic/versions/0039_resource_sponsors.py
create mode 100644 frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
create mode 100644 frontend/src/agents/components/SponsoredResourcesNotice.tsx
create mode 100644 tests/api/user/test_resource_sponsors.py
diff --git a/docsgpt/agents/headless_runner.py b/docsgpt/agents/headless_runner.py
index 5e1413d3..cddf693e 100644
--- a/docsgpt/agents/headless_runner.py
+++ b/docsgpt/agents/headless_runner.py
@@ -18,6 +18,7 @@ from docsgpt.api.answer.services.stream_processor import (
authorized_prompt_id,
get_prompt,
)
+from docsgpt.api.user.resource_access import ref_principal
from docsgpt.core.settings import settings
from docsgpt.quotas.service import QuotaExceededError, QuotaService
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -151,7 +152,13 @@ def _run_agent_headless(
source_active: Any = {}
if source_id:
with db_readonly() as conn:
- src_row = SourcesRepository(conn).get(str(source_id), owner)
+ # Owned or team-shared to the owner, else attached by an editor
+ # who still qualifies; read unscoped once authorized.
+ src_row = (
+ SourcesRepository(conn).get_by_id(str(source_id))
+ if ref_principal(conn, "agent", agent_config, "source", str(source_id))
+ else None
+ )
if src_row:
source_active = str(src_row["id"])
retriever_kind = src_row.get("retriever", retriever_kind)
@@ -161,7 +168,7 @@ def _run_agent_headless(
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
# Runs as the owner: a prompt they can no longer use (revoked grant,
# deleted) falls back to the default instead of rendering anyway.
- prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner)
+ prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner, agent_config)
user_api_key = agent_config.get("key")
agent_id = _resolve_agent_id(agent_config)
agent_type = agent_config.get("agent_type", "classic")
diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py
index a8e043c4..dd9e6f0b 100644
--- a/docsgpt/agents/tool_executor.py
+++ b/docsgpt/agents/tool_executor.py
@@ -505,6 +505,9 @@ class ToolExecutor:
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
# user_tools rows alike — with no defaults mixed in. None = unscoped.
self.allowed_tool_ids: Optional[List[str]] = None
+ # Tool id -> the user to resolve it as, for a workflow node's tools
+ # sponsored by an editor (see resource_access.active_sponsor).
+ self.tool_principals: Dict[str, str] = {}
self.conversation_id: Optional[str] = None
# Set by the workflow engine for agent nodes so run-scoped tools
# (artifact_generator / code_executor) address artifacts by the
@@ -567,6 +570,8 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
+ if row is None and str(tid) in self.tool_principals:
+ row = resolve_tool_by_id(tid, self.tool_principals[str(tid)], user_tools_repo=tools_repo)
if row is None:
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
continue
@@ -588,6 +593,15 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
+ if row is None:
+ # A tool the owner can't use runs as the editor who
+ # attached it, while they still qualify.
+ # Lazy: docsgpt.api's package import pulls in every route module.
+ from docsgpt.api.user.resource_access import active_sponsor
+
+ sponsor = active_sponsor(conn, "agent", agent_data, "tool", str(tid))
+ if sponsor:
+ row = resolve_tool_by_id(tid, sponsor, user_tools_repo=tools_repo)
if row is None:
continue
# Workflow-only builtins (read_document) never resolve for a
diff --git a/docsgpt/agents/workflow_agent.py b/docsgpt/agents/workflow_agent.py
index 2c08bc0c..010f7287 100644
--- a/docsgpt/agents/workflow_agent.py
+++ b/docsgpt/agents/workflow_agent.py
@@ -45,6 +45,7 @@ class WorkflowAgent(BaseAgent):
):
super().__init__(*args, **kwargs)
self.workflow_id = workflow_id
+ self.workflow_row: Optional[Dict[str, Any]] = None
self.workflow_owner = workflow_owner
self._workflow_data = workflow
self._engine: Optional[WorkflowEngine] = None
@@ -195,6 +196,9 @@ class WorkflowAgent(BaseAgent):
if workflow_row is None:
logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}")
return None
+ # Node tools/sources the owner can't use resolve through its
+ # ``resource_sponsors`` (see WorkflowEngine).
+ self.workflow_row = workflow_row
pg_workflow_id = str(workflow_row["id"])
graph_version = workflow_row.get("current_graph_version", 1)
try:
diff --git a/docsgpt/agents/workflows/node_agent.py b/docsgpt/agents/workflows/node_agent.py
index 17fa17a8..45215317 100644
--- a/docsgpt/agents/workflows/node_agent.py
+++ b/docsgpt/agents/workflows/node_agent.py
@@ -19,6 +19,7 @@ class _WorkflowNodeMixin:
model_id: str,
api_key: str,
tool_ids: Optional[List[str]] = None,
+ tool_principals: Optional[Dict[str, str]] = None,
**kwargs,
):
super().__init__(
@@ -34,6 +35,8 @@ class _WorkflowNodeMixin:
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
# alike, and an empty list means the node's LLM gets no tools.
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
+ # Tools the owner can't use resolve as the editor who attached them.
+ self.tool_executor.tool_principals = dict(tool_principals or {})
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
diff --git a/docsgpt/agents/workflows/workflow_engine.py b/docsgpt/agents/workflows/workflow_engine.py
index e7cf437a..22017726 100644
--- a/docsgpt/agents/workflows/workflow_engine.py
+++ b/docsgpt/agents/workflows/workflow_engine.py
@@ -405,6 +405,7 @@ class WorkflowEngine:
"model_user_id": getattr(self.agent, "model_user_id", None),
"api_key": node_api_key,
"tool_ids": node_config.tools,
+ "tool_principals": self._node_tool_principals(node_config.tools),
"prompt": node_prompt,
"chat_history": self.agent.chat_history,
"decoded_token": self.agent.decoded_token,
@@ -1321,6 +1322,36 @@ class WorkflowEngine:
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
return docs, docs_together
+ def _node_tool_principals(self, tool_ids) -> Dict[str, str]:
+ """Node tool id -> the editor to resolve it as, for sponsored tools.
+
+ Only tools with a live sponsor on the workflow appear; the executor
+ still tries the owner first.
+
+ Args:
+ tool_ids: The node's configured tool ids.
+
+ Returns:
+ dict: ``tool_id -> sponsor`` user id.
+ """
+ workflow_row = getattr(self.agent, "workflow_row", None)
+ if not tool_ids or not workflow_row or not workflow_row.get("resource_sponsors"):
+ return {}
+ from docsgpt.api.user.resource_access import active_sponsor
+ from docsgpt.storage.db.session import db_readonly
+
+ principals: Dict[str, str] = {}
+ try:
+ with db_readonly() as conn:
+ for tid in tool_ids:
+ sponsor = active_sponsor(conn, "workflow", workflow_row, "tool", str(tid))
+ if sponsor:
+ principals[str(tid)] = sponsor
+ except Exception:
+ logger.exception("Workflow node tool sponsor lookup failed; using the owner only.")
+ return {}
+ return principals
+
def _authorized_node_sources(self, sources) -> list:
"""Filter a node's configured source ids to those its owner may read.
@@ -1329,7 +1360,9 @@ class WorkflowEngine:
tenant's source id and the retriever — which filters only on
``source_id`` — handed the documents back. Gate on the workflow owner
(not the runner): a shared workflow legitimately reads its owner's
- sources, exactly like a shared agent does.
+ sources, exactly like a shared agent does. A source the owner can't
+ read still passes while the editor who attached it (its sponsor)
+ qualifies.
Args:
sources: Source ids from the stored node config.
@@ -1348,14 +1381,19 @@ class WorkflowEngine:
logger.warning("Workflow node sources dropped: no owner to authorize.")
return []
+ from docsgpt.api.user.resource_access import active_sponsor
from docsgpt.api.user.team_sharing import can_access
from docsgpt.storage.db.session import db_readonly
+ workflow_row = getattr(self.agent, "workflow_row", None)
allowed = []
try:
with db_readonly() as conn:
for sid in ids:
- if sid and can_access(conn, "source", str(sid), owner):
+ if sid and (
+ can_access(conn, "source", str(sid), owner)
+ or active_sponsor(conn, "workflow", workflow_row, "source", str(sid))
+ ):
allowed.append(sid)
else:
logger.warning(
diff --git a/docsgpt/alembic/versions/0039_resource_sponsors.py b/docsgpt/alembic/versions/0039_resource_sponsors.py
new file mode 100644
index 00000000..2fc4f5df
--- /dev/null
+++ b/docsgpt/alembic/versions/0039_resource_sponsors.py
@@ -0,0 +1,40 @@
+"""0039 resource sponsors — who vouches for a saved resource the owner can't use.
+
+An agent (and a workflow) runs as its owner, so every source, prompt and tool
+it references is authorized against the owner. A team editor who attaches
+their own private tool, prompt or source would have it dropped at run time.
+``resource_sponsors`` records the editor who attached such a resource, keyed
+``":"`` (e.g. ``{"tool:": "bob"}``). At run time the resource
+is authorized as that sponsor, provided they can still edit the agent and
+still use the resource (``docsgpt/api/user/resource_access.py``).
+
+An empty map means today's behaviour: owner-only authorization.
+
+Idempotent both ways.
+
+Revision ID: 0039_resource_sponsors
+Revises: 0038_resource_access_settings
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0039_resource_sponsors"
+down_revision: Union[str, None] = "0038_resource_access_settings"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ for table in ("agents", "workflows"):
+ op.execute(
+ f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS "
+ "resource_sponsors JSONB NOT NULL DEFAULT '{}'::jsonb;"
+ )
+
+
+def downgrade() -> None:
+ for table in ("agents", "workflows"):
+ op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS resource_sponsors;")
diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py
index c72b1055..b453aff0 100644
--- a/docsgpt/api/answer/services/stream_processor.py
+++ b/docsgpt/api/answer/services/stream_processor.py
@@ -112,16 +112,19 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str:
_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",)
-def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
- """``prompt_id`` if ``principal`` may use it, else ``"default"``.
+def authorized_prompt_id(prompt_id: Any, principal: Optional[str], agent: Optional[dict] = None) -> Any:
+ """``prompt_id`` if ``principal`` (or the agent's sponsor) may use it, else ``"default"``.
Presets pass through. A custom prompt must be owned by ``principal`` or
- reach them through a team grant with ``use`` (checked live); a revoked,
- deleted or foreign prompt falls back to the default prompt.
+ reach them through a team grant with ``use`` (checked live). On an agent
+ run, a prompt the owner can't use still renders while the editor who
+ attached it (its sponsor) qualifies. A revoked, deleted or foreign prompt
+ falls back to the default prompt.
Args:
prompt_id: The configured prompt (preset name, UUID or legacy id).
principal: The agent owner for an agent run, else the caller.
+ agent: The agent row on an agent run, for its ``resource_sponsors``.
Returns:
The prompt id to render.
@@ -131,20 +134,38 @@ def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
pid = str(prompt_id)
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
return prompt_id
- from docsgpt.api.user.resource_access import resolve
+ from docsgpt.api.user.resource_access import active_sponsor, resolve
try:
with db_readonly() as conn:
ra = resolve(conn, "prompt", pid, principal) if principal else None
+ usable = ra is not None and ra.can("use")
+ if not usable and agent and agent.get("id"):
+ usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
except Exception:
logger.exception("Prompt access check failed for %s", pid)
- ra = None
- if ra is not None and ra.can("use"):
+ usable = False
+ if usable:
return prompt_id
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
return "default"
+def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any) -> Optional[dict]:
+ """The source row an agent may retrieve from, or None.
+
+ Authorized as the owner (owned or team-shared to them), else as the
+ editor who attached it while they still qualify. Read unscoped once
+ authorized: an owner-scoped read misses a team-shared source.
+ """
+ from docsgpt.api.user.resource_access import ref_principal
+
+ if not ref_principal(conn, "agent", agent, "source", str(source_id)):
+ logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
+ return None
+ return sources_repo.get_by_id(str(source_id))
+
+
def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]:
"""The owner id to write a wiki source as, when ``caller`` may edit it."""
from docsgpt.api.user.resource_access import resolve
@@ -720,13 +741,12 @@ class StreamProcessor:
# the legacy ``data["source"]`` slot.
sources_list: list = []
seen: set = set()
- owner = agent.get("user_id")
primary_id = agent.get("source_id")
# ``sources`` row may have NULL ``retriever``/``chunks`` —
# fall back to the agent's value (``dict.get`` returns None
# even when the key exists with value None).
if primary_id:
- source_doc = sources_repo.get(str(primary_id), owner)
+ source_doc = _agent_source_doc(conn, sources_repo, agent, primary_id)
if source_doc:
sid = str(source_doc["id"])
data["source"] = sid
@@ -759,7 +779,7 @@ class StreamProcessor:
for sid_raw in agent.get("extra_source_ids") or []:
if not sid_raw:
continue
- source_doc = sources_repo.get(str(sid_raw), owner)
+ source_doc = _agent_source_doc(conn, sources_repo, agent, sid_raw)
if not source_doc:
continue
sid = str(source_doc["id"])
@@ -983,6 +1003,7 @@ class StreamProcessor:
"prompt_id": authorized_prompt_id(
self._agent_data.get("prompt_id", "default"),
self._agent_data.get("user"),
+ self._agent_data,
),
"agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME),
"user_api_key": effective_key,
diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py
index 6b9daf45..1711b039 100644
--- a/docsgpt/api/user/agents/routes.py
+++ b/docsgpt/api/user/agents/routes.py
@@ -28,11 +28,14 @@ from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.api.user.resource_access import (
AccessDenied,
+ agent_refs,
delete_settings,
payload_for,
require,
resolve,
settings_many,
+ sponsor_details,
+ sponsors_after_save,
)
from docsgpt.api.user.team_sharing import (
can_access,
@@ -536,12 +539,16 @@ class GetAgent(Resource):
try:
user = decoded_token["sub"]
agent = None
+ sponsored: list = []
with db_readonly() as conn:
# Anyone who can see the agent reads it (a viewer needs it to
# chat); what they get back is trimmed by their actions.
ra = resolve(conn, "agent", agent_id, user)
if ra is not None:
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
+ # Edit-page detail: who vouches for resources the owner can't use.
+ if agent and ra.can("view"):
+ sponsored = sponsor_details(conn, "agent", agent)
if not agent:
return {"status": "Not found"}, 404
is_owner = ra.access == "owner"
@@ -552,6 +559,7 @@ class GetAgent(Resource):
resolve_names=True,
access=ra.payload(),
)
+ data["resource_sponsors"] = sponsored
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
@@ -1383,6 +1391,18 @@ class UpdateAgent(Resource):
403,
)
+ # A resource the owner can't use runs as the editor who
+ # attached it (its sponsor); record who that is.
+ after_save = dict(existing_agent)
+ for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"):
+ if ref_field in update_fields:
+ after_save[ref_field] = update_fields[ref_field]
+ sponsors = sponsors_after_save(
+ conn, "agent", existing_agent, owner_id, user, agent_refs(after_save)
+ )
+ if sponsors != (existing_agent.get("resource_sponsors") or {}):
+ update_fields["resource_sponsors"] = sponsors
+
# Guardrails and the pooled quota are policy: an unchanged
# value re-sent by a full-form save is fine, a change needs
# ``edit_policy``.
diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py
index 574e9487..35a19322 100644
--- a/docsgpt/api/user/resource_access.py
+++ b/docsgpt/api/user/resource_access.py
@@ -21,6 +21,7 @@ revoked grant or membership denies on the next request.
from __future__ import annotations
import json
+import logging
from dataclasses import dataclass, field
from typing import Iterable, Optional
@@ -36,6 +37,8 @@ from docsgpt.storage.db.repositories.team_resource_grants import (
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+logger = logging.getLogger(__name__)
+
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
# Weakest role that may perform each action by default. ``owner`` rows are
@@ -332,3 +335,217 @@ def require(
if not ra.can(action):
raise AccessDenied(403, "Your access to this item doesn't allow that")
return ra
+
+
+# --- Resource sponsors ------------------------------------------------------
+#
+# An agent (or workflow) runs as its owner, so a source, prompt or tool it
+# references is authorized against the owner. When a team editor attaches one
+# the owner can't use, the editor becomes its *sponsor*: the holder row's
+# ``resource_sponsors`` maps ``":"`` to the editor's id, and at run
+# time the resource is authorized as the sponsor while they can still edit the
+# holder and still use the resource. A tool still runs with its own row's
+# credentials (the tool owner's), whoever the principal is.
+
+# Action a principal needs on a referenced resource for a holder to run it.
+REF_USE_ACTION = {"source": "use", "prompt": "use", "tool": "use_in_own"}
+
+
+def sponsor_key(resource_type: str, resource_id: str) -> str:
+ """The ``resource_sponsors`` key for one referenced resource."""
+ return f"{resource_type}:{resource_id}"
+
+
+def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
+ """Whether ``user_id`` may have ``resource_id`` run inside something they hold.
+
+ Args:
+ conn: Open database connection.
+ resource_type: ``source``, ``prompt`` or ``tool``.
+ resource_id: The referenced id.
+ user_id: The would-be principal.
+
+ Returns:
+ True when the user owns the resource or a team grant gives them
+ ``use`` (``use_in_own`` for a tool).
+ """
+ if not user_id or not resource_id:
+ return False
+ ra = resolve(conn, resource_type, str(resource_id), user_id)
+ return ra is not None and ra.can(REF_USE_ACTION[resource_type])
+
+
+def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: str) -> bool:
+ """Whether ``user_id`` may still edit the agent or workflow ``holder``.
+
+ A workflow is edited through an agent of its owner that uses it, so the
+ check is ``edit`` on any such agent (mirrors the workflow routes).
+ """
+ if holder_type == "agent":
+ ra = resolve(conn, "agent", str(holder["id"]), user_id)
+ return ra is not None and ra.can("edit")
+ if holder_type == "workflow":
+ agent_ids = conn.execute(
+ text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"),
+ {"wid": str(holder["id"]), "owner": holder.get("user_id")},
+ ).scalars().all()
+ for agent_id in agent_ids:
+ ra = resolve(conn, "agent", str(agent_id), user_id)
+ if ra is not None and ra.can("edit"):
+ return True
+ return False
+ raise ValueError(f"Unknown sponsor holder type: {holder_type}")
+
+
+def active_sponsor(
+ conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
+) -> Optional[str]:
+ """The sponsor a holder may run ``resource_id`` as, checked live.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``).
+ resource_type: ``source``, ``prompt`` or ``tool``.
+ resource_id: The referenced id.
+
+ Returns:
+ The sponsor's id when one is recorded, still edits the holder and
+ can still use the resource; else None.
+ """
+ if not holder or not resource_id:
+ return None
+ sponsor = (holder.get("resource_sponsors") or {}).get(sponsor_key(resource_type, str(resource_id)))
+ if not sponsor or sponsor == holder.get("user_id"):
+ return None
+ try:
+ if not _holder_editable_by(conn, holder_type, holder, sponsor):
+ return None
+ return sponsor if can_use_ref(conn, resource_type, str(resource_id), sponsor) else None
+ except Exception:
+ logger.exception("Sponsor check failed for %s %s", resource_type, resource_id)
+ return None
+
+
+def ref_principal(
+ conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
+) -> Optional[str]:
+ """The user a holder's referenced resource is authorized as, or None.
+
+ The owner when they may use it (the default), else a live sponsor.
+ """
+ if not holder:
+ return None
+ owner = holder.get("user_id")
+ if can_use_ref(conn, resource_type, str(resource_id), owner):
+ return owner
+ return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
+
+
+def _sponsorable(resource_type: str, resource_id: str) -> bool:
+ """Only real rows need a principal: skip presets and builtin tool ids."""
+ rid = str(resource_id or "")
+ if not looks_like_uuid(rid):
+ return False
+ if resource_type == "tool":
+ # Lazy: default_tools imports this module lazily too.
+ from docsgpt.agents.default_tools import is_synthesized_tool_id
+
+ return not is_synthesized_tool_id(rid)
+ return True
+
+
+def agent_refs(agent: dict) -> list[tuple[str, str]]:
+ """The ``(type, id)`` resources an agent row references."""
+ refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s]
+ if agent.get("prompt_id"):
+ refs.append(("prompt", str(agent["prompt_id"])))
+ refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t)
+ return refs
+
+
+def sponsors_after_save(
+ conn: Connection,
+ holder_type: str,
+ holder: Optional[dict],
+ owner_id: str,
+ caller: str,
+ refs: Iterable[tuple[str, str]],
+) -> dict[str, str]:
+ """The ``resource_sponsors`` map to store after ``caller`` saves ``refs``.
+
+ Per referenced resource the owner can't use: a recorded sponsor who still
+ qualifies is kept; otherwise the caller takes it over when they can use it
+ (a new attachment, or one whose sponsor lost access); otherwise the old
+ record is kept so the editor can show who added it. Resources the owner
+ can use, presets and builtin tools need no sponsor. Removed refs drop out.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row before the save (None when creating it).
+ owner_id: The holder's owner.
+ caller: The user saving.
+ refs: Every ``(type, id)`` the holder references after the save.
+
+ Returns:
+ dict: ``":" -> user_id``.
+ """
+ previous = (holder or {}).get("resource_sponsors") or {}
+ out: dict[str, str] = {}
+ for resource_type, resource_id in refs:
+ key = sponsor_key(resource_type, resource_id)
+ if key in out or not _sponsorable(resource_type, resource_id):
+ continue
+ if can_use_ref(conn, resource_type, resource_id, owner_id):
+ continue
+ if active_sponsor(conn, holder_type, holder, resource_type, resource_id):
+ out[key] = previous[key]
+ elif caller != owner_id and can_use_ref(conn, resource_type, resource_id, caller):
+ out[key] = caller
+ elif previous.get(key):
+ out[key] = previous[key]
+ return out
+
+
+def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]:
+ """The holder's sponsored resources for its edit page.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row.
+
+ Returns:
+ list: ``{type, id, user_id, label, active}`` per sponsored resource;
+ ``label`` is the sponsor's email when on file, ``active`` whether it
+ runs (the sponsor still edits the holder and can use the resource).
+ """
+ sponsors = holder.get("resource_sponsors") or {}
+ if not sponsors:
+ return []
+ user_ids = sorted({u for u in sponsors.values() if u})
+ labels = dict(
+ conn.execute(
+ text(
+ "SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
+ "AND email IS NOT NULL AND email <> ''"
+ ),
+ {"ids": user_ids},
+ ).fetchall()
+ ) if user_ids else {}
+ out = []
+ for key, user_id in sponsors.items():
+ resource_type, _, resource_id = key.partition(":")
+ if resource_type not in REF_USE_ACTION or not resource_id:
+ continue
+ out.append(
+ {
+ "type": resource_type,
+ "id": resource_id,
+ "user_id": user_id,
+ "label": labels.get(user_id) or user_id,
+ "active": active_sponsor(conn, holder_type, holder, resource_type, resource_id) is not None,
+ }
+ )
+ return out
diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py
index 3faad82e..1e48899a 100644
--- a/docsgpt/api/user/workflows/routes.py
+++ b/docsgpt/api/user/workflows/routes.py
@@ -1,6 +1,6 @@
"""Workflow management routes."""
-from typing import Any, Dict, List, Optional, Set
+from typing import Any, Dict, List, Optional, Set, Tuple
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
@@ -10,7 +10,12 @@ from docsgpt.agents.workflows.cel_evaluator import (
CelEvaluationError,
validate_cel_expression,
)
-from docsgpt.api.user.resource_access import AccessDenied, resolve
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ resolve,
+ sponsor_details,
+ sponsors_after_save,
+)
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
@@ -95,6 +100,30 @@ def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
raise AccessDenied(403, "Your access to this item doesn't allow that")
+def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
+ """The ``(type, id)`` tools and sources a workflow's agent nodes reference.
+
+ Args:
+ nodes: Nodes as the builder sends them (config under ``data`` or
+ ``data.config``, like the engine reads it).
+
+ Returns:
+ list: ``("tool", id)`` and ``("source", id)`` pairs.
+ """
+ refs: List[Tuple[str, str]] = []
+ for node in nodes or []:
+ if not isinstance(node, dict) or node.get("type") != "agent":
+ continue
+ data = node.get("data") or {}
+ cfg = data.get("config") if isinstance(data.get("config"), dict) else data
+ for resource_type, key in (("tool", "tools"), ("source", "sources")):
+ values = cfg.get(key) or []
+ if isinstance(values, (str, int)):
+ values = [values]
+ refs.extend((resource_type, str(v)) for v in values if v)
+ return refs
+
+
def _denied(err: AccessDenied):
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
return make_response(
@@ -567,6 +596,7 @@ class WorkflowDetail(Resource):
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
+ sponsored = sponsor_details(conn, "workflow", workflow)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
@@ -575,6 +605,7 @@ class WorkflowDetail(Resource):
"workflow": serialize_workflow(workflow),
"nodes": [serialize_node(n) for n in nodes],
"edges": [serialize_edge(e) for e in edges],
+ "resource_sponsors": sponsored,
}
)
@@ -614,14 +645,19 @@ class WorkflowDetail(Resource):
conn, pg_workflow_id, next_graph_version,
nodes_data, edges_data,
)
- repo.update(
- pg_workflow_id, acting,
- {
- "name": name,
- "description": description,
- "current_graph_version": next_graph_version,
- },
+ workflow_fields = {
+ "name": name,
+ "description": description,
+ "current_graph_version": next_graph_version,
+ }
+ # A node tool/source the owner can't use runs as the editor
+ # who attached it (its sponsor); record who that is.
+ sponsors = sponsors_after_save(
+ conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data)
)
+ if sponsors != (workflow.get("resource_sponsors") or {}):
+ workflow_fields["resource_sponsors"] = sponsors
+ repo.update(pg_workflow_id, acting, workflow_fields)
WorkflowNodesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
)
diff --git a/docsgpt/services/search_service.py b/docsgpt/services/search_service.py
index 68756c91..3fd55e4e 100644
--- a/docsgpt/services/search_service.py
+++ b/docsgpt/services/search_service.py
@@ -48,7 +48,7 @@ def _collect_source_ids(agent: Dict[str, Any]) -> List[str]:
def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -> List[str]:
- """Drop source ids the agent's owner may not read.
+ """Drop source ids the agent may not read.
``_collect_source_ids`` trusts whatever the agent row carries, and this
service searches those ids directly. That made it the second half of a
@@ -64,20 +64,23 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
agent: The agent row resolved from the API key.
source_ids: Ids extracted from that row.
+ A source the owner can't read still searches while the editor who
+ attached it (its sponsor) can edit the agent and read the source.
+
Returns:
- list: The subset the agent's owner may read.
+ list: The subset the agent's owner (or a live sponsor) may read.
"""
owner = agent.get("user_id")
if not owner:
logger.warning("Agent %s has no owner; refusing to search its sources.", agent.get("id"))
return []
- from docsgpt.api.user.team_sharing import can_access
+ from docsgpt.api.user.resource_access import ref_principal
allowed = []
for sid in source_ids:
try:
- permitted = can_access(conn, "source", str(sid), owner)
+ permitted = ref_principal(conn, "agent", agent, "source", str(sid)) is not None
except Exception:
# Fail closed, matching the answer path.
logger.warning("Access check failed for source %s; dropping it.", sid)
diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py
index 1e6c3866..34841da6 100644
--- a/docsgpt/storage/db/models.py
+++ b/docsgpt/storage/db/models.py
@@ -400,6 +400,9 @@ agents_table = Table(
# Per-agent behavior contract (AgentConfig — guardrails today). Empty
# ``{}`` parses to guardrails-disabled.
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
+ # ``":" -> user_id`` of the editor vouching for a referenced
+ # resource the owner can't use (migration 0039, see resource_access.py).
+ Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("default_model_id", Text),
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
@@ -958,6 +961,8 @@ workflows_table = Table(
Column("name", Text, nullable=False),
Column("description", Text),
Column("current_graph_version", Integer, nullable=False, server_default="1"),
+ # Same shape as ``agents.resource_sponsors``, for node tools and sources.
+ Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("legacy_mongo_id", Text),
diff --git a/docsgpt/storage/db/repositories/agents.py b/docsgpt/storage/db/repositories/agents.py
index 2e8163dc..78c2bb80 100644
--- a/docsgpt/storage/db/repositories/agents.py
+++ b/docsgpt/storage/db/repositories/agents.py
@@ -50,7 +50,7 @@ class AgentsRepository:
for col, val in kwargs.items():
if col not in _ALLOWED or val is None:
continue
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# JSONB columns: pass the Python object directly. SQLAlchemy
# Core's JSONB type processor json.dumps it once during
# bind; pre-serialising would double-encode and the value
@@ -234,7 +234,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
- "config",
+ "config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -248,7 +248,7 @@ class AgentsRepository:
return False
values: dict = {}
for col, val in filtered.items():
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
values[col] = val
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
values[col] = str(val) if val else None
@@ -294,7 +294,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
- "config",
+ "config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -309,7 +309,7 @@ class AgentsRepository:
values: dict = {}
for col, val in filtered.items():
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# See note in create(): JSONB columns receive Python
# objects, the type processor handles serialisation.
values[col] = val
diff --git a/docsgpt/storage/db/repositories/workflows.py b/docsgpt/storage/db/repositories/workflows.py
index b27b45e4..8f3563bf 100644
--- a/docsgpt/storage/db/repositories/workflows.py
+++ b/docsgpt/storage/db/repositories/workflows.py
@@ -9,6 +9,7 @@ Covers CRUD on workflow metadata:
from __future__ import annotations
import copy
+import json
from typing import Optional
from sqlalchemy import Connection, text
@@ -85,12 +86,17 @@ class WorkflowsRepository:
return [row_to_dict(r) for r in result.fetchall()]
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
- allowed = {"name", "description", "current_graph_version"}
+ allowed = {"name", "description", "current_graph_version", "resource_sponsors"}
filtered = {k: v for k, v in fields.items() if k in allowed}
if not filtered:
return False
- set_parts = [f"{col} = :{col}" for col in filtered]
+ set_parts = [
+ f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}"
+ for col in filtered
+ ]
+ if "resource_sponsors" in filtered:
+ filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {})
set_parts.append("updated_at = now()")
params = {**filtered, "id": workflow_id, "user_id": user_id}
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 82c412cb..b2c8b657 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -983,7 +983,11 @@ the current page size, so picking a bigger size never hides the way back
("Page 1 of 1", chevrons off). Counts and numbers in the UI format on the app
language: `formatCount` (`utils/dateTimeUtils`, `Intl` on `intlLocale()`), never
`toLocaleString()` or a raw `{{count}}`; plural keys get the number as `count`
-and the formatted text as its own param. The one exception is a headline
+and the formatted text as `formatted` (`{{formatted}} members`), a key with no
+plural passes only `formatted`, and a count is never baked into the key name
+(`memberCountOne`). `jp` and `zhTW` aren't language tags i18next knows, so it
+plurals them by English rules: every plural key there has an `_one` form too
+(the same text as `_other`), or a count of 1 falls back to English. The one exception is a headline
total that can reach tens of thousands (the chunk count in the Chunks byline
and embedded toolbar): it may use `abbreviateCount` (`components/chunkUtils`,
`Intl` compact notation: "12K", "12 тыс.", "1.2万"; grouped digits where the
diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx
index fc82c0e3..d5ef020f 100644
--- a/frontend/src/agents/NewAgent.tsx
+++ b/frontend/src/agents/NewAgent.tsx
@@ -91,7 +91,8 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice';
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
import AgentPreviewSheet from './components/AgentPreviewSheet';
import SectionShell from '../navigation/SectionShell';
-import { Agent, ToolSummary } from './types';
+import SponsoredResourcesNotice from './components/SponsoredResourcesNotice';
+import { Agent, ResourceSponsor, ToolSummary } from './types';
import WorkflowBuilder from './workflow/WorkflowBuilder';
import type { Model } from '../models/types';
@@ -282,6 +283,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
[agent.source_details, sourceDocs, t],
);
+ // Name of a tool/source/prompt that runs with an editor's access, from the
+ // same owner-agnostic details the pickers show.
+ const resolveSponsoredName = useCallback(
+ (sponsor: ResourceSponsor): string => {
+ if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id);
+ if (sponsor.type === 'prompt') {
+ return (
+ prompts.find((prompt) => prompt.id === sponsor.id)?.name ||
+ agent.prompt_name ||
+ t('agents.form.sponsors.unknownItem')
+ );
+ }
+ const tool = selectedTools.find((item) => item.id === sponsor.id);
+ return tool
+ ? getToolDisplayName(tool)
+ : t('agents.form.sponsors.unknownItem');
+ },
+ [agent.prompt_name, prompts, resolveSourceLabel, selectedTools, t],
+ );
+
const sourceItems = useMemo(() => {
const items = toSourcePickerItems(
sourceDocs,
@@ -1185,6 +1206,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{t('agents.form.buttons.add')}
+
diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
new file mode 100644
index 00000000..fa3a510f
--- /dev/null
+++ b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
@@ -0,0 +1,103 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+import type { Agent, ResourceSponsor } from '../types';
+import SponsoredResourcesNotice from './SponsoredResourcesNotice';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const baseAgent: Agent = {
+ name: 'A',
+ description: 'd',
+ image: '',
+ source: '',
+ chunks: '6',
+ retriever: '',
+ prompt_id: 'default',
+ tools: [],
+ agent_type: 'classic',
+ status: 'published',
+};
+
+const sponsor = (over: Partial): ResourceSponsor => ({
+ type: 'tool',
+ id: 't1',
+ user_id: 'bob',
+ label: 'bob@example.com',
+ active: true,
+ ...over,
+});
+
+describe('SponsoredResourcesNotice', () => {
+ 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();
+ });
+
+ const render = async (agent: Agent) => {
+ await act(async () => {
+ root.render(
+ `name-${s.id}`}
+ />,
+ );
+ });
+ };
+
+ const alerts = () =>
+ Array.from(container.querySelectorAll('[data-slot="alert"]'));
+
+ it('renders nothing for the owner with no sponsored items', async () => {
+ await render(baseAgent);
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('tells an editor their own items run with their access', async () => {
+ await render({ ...baseAgent, access: 'editor', allowed_actions: ['edit'] });
+ expect(alerts()).toHaveLength(1);
+ expect(container.textContent).toContain('agents.form.sponsors.attachNote');
+ expect(container.textContent).not.toContain('publicLinkNote');
+ });
+
+ it('adds the public-link warning when the agent has a link', async () => {
+ await render({
+ ...baseAgent,
+ shared: true,
+ access: 'editor',
+ allowed_actions: ['edit'],
+ });
+ expect(container.textContent).toContain(
+ 'agents.form.sponsors.publicLinkNote',
+ );
+ });
+
+ it('does not show the attach note to a viewer', async () => {
+ await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] });
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('splits running and no-longer-running items', async () => {
+ await render({
+ ...baseAgent,
+ resource_sponsors: [
+ sponsor({ id: 't1' }),
+ sponsor({ id: 's1', type: 'source', active: false }),
+ ],
+ });
+ const [running, stopped] = alerts();
+ expect(running.getAttribute('role')).toBe('note');
+ expect(running.textContent).toContain('agents.form.sponsors.addedBy');
+ expect(stopped.getAttribute('data-variant')).toBe('warning');
+ expect(stopped.textContent).toContain('agents.form.sponsors.unavailable');
+ });
+});
diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.tsx
new file mode 100644
index 00000000..79d8fd26
--- /dev/null
+++ b/frontend/src/agents/components/SponsoredResourcesNotice.tsx
@@ -0,0 +1,91 @@
+import { Info, TriangleAlert, UserRound } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+import { can, isOwner } from '../../utils/accessUtils';
+import type { Agent, ResourceSponsor } from '../types';
+
+type SponsoredResourcesNoticeProps = {
+ agent: Agent;
+ /** Display name of a sponsored tool, source or prompt. */
+ resolveName: (sponsor: ResourceSponsor) => string;
+};
+
+/** Sponsored items grouped by the person who added them, in first-seen order. */
+function groupByPerson(sponsors: ResourceSponsor[]) {
+ const groups = new Map();
+ for (const sponsor of sponsors) {
+ const key = sponsor.label || sponsor.user_id;
+ groups.set(key, [...(groups.get(key) ?? []), sponsor]);
+ }
+ return Array.from(groups.entries());
+}
+
+/**
+ * Tools, sources and prompts on the agent that run with an editor's access
+ * rather than the owner's (`resource_sponsors` from GET /api/get_agent).
+ *
+ * An editor sees up front that what they attach runs with their access for
+ * everyone who uses the agent. Everyone who may view the config sees who
+ * added what, and which items no longer run because that person lost access.
+ */
+export default function SponsoredResourcesNotice({
+ agent,
+ resolveName,
+}: SponsoredResourcesNoticeProps) {
+ const { t } = useTranslation();
+ const sponsors = agent.resource_sponsors ?? [];
+ const showAttachNote = !isOwner(agent) && can(agent, 'edit');
+ const active = groupByPerson(sponsors.filter((s) => s.active));
+ const inactive = groupByPerson(sponsors.filter((s) => !s.active));
+
+ if (!showAttachNote && sponsors.length === 0) return null;
+
+ const names = (items: ResourceSponsor[]) =>
+ items.map((item) => resolveName(item)).join(', ');
+
+ return (
+
+ {showAttachNote && (
+
+
+
+ {t('agents.form.sponsors.attachNote')}
+ {agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''}
+
+
+ )}
+ {active.length > 0 && (
+
+
+
+ {active.map(([person, items]) => (
+
+ {t('agents.form.sponsors.addedBy', {
+ person,
+ names: names(items),
+ })}
+
+ ))}
+
+
+ )}
+ {inactive.length > 0 && (
+
+
+
+ {inactive.map(([person, items]) => (
+
+ {t('agents.form.sponsors.unavailable', {
+ person,
+ names: names(items),
+ })}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts
index 8fa98a32..8b9cd82b 100644
--- a/frontend/src/agents/types/index.ts
+++ b/frontend/src/agents/types/index.ts
@@ -6,6 +6,17 @@ export type ToolSummary = {
display_name: string;
};
+/** A tool, source or prompt that runs with the editor's access who added it. */
+export type ResourceSponsor = {
+ type: 'tool' | 'source' | 'prompt';
+ id: string;
+ user_id: string;
+ /** The person's email when on file, else their user id. */
+ label: string;
+ /** False once that person can no longer edit the agent or use the item. */
+ active: boolean;
+};
+
export type Agent = {
id?: string;
name: string;
@@ -41,6 +52,9 @@ export type Agent = {
// names for resources the caller themselves owns).
prompt_name?: string | null;
source_details?: { id: string; name: string | null }[];
+ // Resources the owner can't use that run as the editor who attached them
+ // (GET /api/get_agent, callers who may view the config).
+ resource_sponsors?: ResourceSponsor[];
created_at?: string;
updated_at?: string;
last_used_at?: string;
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index fe774f70..4232dde4 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -676,9 +676,10 @@
"createTeamError": "Team konnte nicht erstellt werden.",
"noTeams": "Noch keine Teams.",
"noDescription": "Keine Beschreibung",
- "memberCountOne": "{{count}} Mitglied",
- "memberCountOther": "{{count}} Mitglieder",
- "sharedCount": "{{count}} geteilt",
+ "memberCount_one": "{{formatted}} Mitglied",
+ "memberCount_other": "{{formatted}} Mitglieder",
+ "sharedCount_one": "{{formatted}} geteilt",
+ "sharedCount_other": "{{formatted}} geteilt",
"loadError": "Teams konnten nicht geladen werden.",
"roleAdmin": "Admin",
"roleMember": "Mitglied",
@@ -746,8 +747,9 @@
"viaTeam": "über {{team}}",
"removeAccess": "Zugriff entfernen",
"access": "Zugriff",
- "showAll": "Alle {{count}} anzeigen",
- "andMore": "und {{count}} weitere",
+ "showAll": "Alle {{formatted}} anzeigen",
+ "andMore_one": "und {{formatted}} weitere",
+ "andMore_other": "und {{formatted}} weitere",
"back": "Zurück",
"allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
"searchAccess": "Personen und Teams suchen…",
@@ -887,7 +889,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} Bearbeiter",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} Bearbeiter",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} Bearbeiter",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Geteilte Ressourcen filtern",
"filter": {
@@ -2007,6 +2010,13 @@
},
"status": {
"published": "Veröffentlicht"
+ },
+ "sponsors": {
+ "attachNote": "Tools, Quellen und Prompts, die du aus deiner eigenen Bibliothek hinzufügst, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
+ "publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
+ "addedBy": "Hinzugefügt von {{person}}: {{names}}",
+ "unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
+ "unknownItem": "Unbenanntes Element"
}
},
"logs": {
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 9a6f4768..1d651430 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -682,9 +682,10 @@
"createTeamError": "Could not create team.",
"noTeams": "No teams yet.",
"noDescription": "No description",
- "memberCountOne": "{{count}} member",
- "memberCountOther": "{{count}} members",
- "sharedCount": "{{count}} shared",
+ "memberCount_one": "{{formatted}} member",
+ "memberCount_other": "{{formatted}} members",
+ "sharedCount_one": "{{formatted}} shared",
+ "sharedCount_other": "{{formatted}} shared",
"loadError": "Failed to load teams.",
"roleAdmin": "admin",
"roleMember": "member",
@@ -752,8 +753,9 @@
"viaTeam": "via {{team}}",
"removeAccess": "Remove access",
"access": "Access",
- "showAll": "Show all {{count}}",
- "andMore": "and {{count}} more",
+ "showAll": "Show all {{formatted}}",
+ "andMore_one": "and {{formatted}} more",
+ "andMore_other": "and {{formatted}} more",
"back": "Back",
"allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
"searchAccess": "Search people and teams…",
@@ -893,7 +895,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} Editor",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} editors",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Filter shared resources",
"filter": {
@@ -2025,6 +2028,13 @@
},
"status": {
"published": "Published"
+ },
+ "sponsors": {
+ "attachNote": "Tools, sources and prompts you add from your own library run with your access for everyone who uses this agent.",
+ "publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
+ "addedBy": "Added by {{person}}: {{names}}",
+ "unavailable": "Not running: {{names}}. {{person}} added them and no longer has access. Remove them or choose ones the owner can use.",
+ "unknownItem": "Unnamed item"
}
},
"logs": {
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 4d539806..fec0c542 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -676,9 +676,10 @@
"createTeamError": "No se pudo crear el equipo.",
"noTeams": "Aún no hay equipos.",
"noDescription": "Sin descripción",
- "memberCountOne": "{{count}} miembro",
- "memberCountOther": "{{count}} miembros",
- "sharedCount": "{{count}} compartidos",
+ "memberCount_one": "{{formatted}} miembro",
+ "memberCount_other": "{{formatted}} miembros",
+ "sharedCount_one": "{{formatted}} compartido",
+ "sharedCount_other": "{{formatted}} compartidos",
"loadError": "No se pudieron cargar los equipos.",
"roleAdmin": "admin",
"roleMember": "miembro",
@@ -746,8 +747,9 @@
"viaTeam": "vía {{team}}",
"removeAccess": "Quitar acceso",
"access": "Acceso",
- "showAll": "Ver los {{count}}",
- "andMore": "y {{count}} más",
+ "showAll": "Ver los {{formatted}}",
+ "andMore_one": "y {{formatted}} más",
+ "andMore_other": "y {{formatted}} más",
"back": "Atrás",
"allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
"searchAccess": "Buscar personas y equipos…",
@@ -887,7 +889,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} Editor",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} editor",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} editores",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Filtrar recursos compartidos",
"filter": {
@@ -2007,6 +2010,13 @@
},
"status": {
"published": "Publicado"
+ },
+ "sponsors": {
+ "attachNote": "Las herramientas, fuentes y prompts que añadas desde tu propia biblioteca se ejecutan con tu acceso para todos los que usen este agente.",
+ "publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
+ "addedBy": "Añadido por {{person}}: {{names}}",
+ "unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no tiene acceso. Quítalos o elige otros que el propietario pueda usar.",
+ "unknownItem": "Elemento sin nombre"
}
},
"logs": {
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index b2d5ac3a..e065eb0d 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -176,6 +176,7 @@
}
},
"badge": "リビングWiki",
+ "byline_one": "{{pages}} ページ · {{tokens}} トークン",
"byline_other": "{{pages}} ページ · {{tokens}} トークン",
"explainer": "エージェントは作業しながらこれらのページを読み、書き換えます。",
"explainerEditable": "エージェントは作業しながらこれらのページを読み、書き換えます。このソースを使うエージェントは誰でも編集できます。",
@@ -234,6 +235,7 @@
"byConnections": "(接続数順)",
"typeFilter": "種類で絞り込む",
"otherTypes": "その他: {{types}}",
+ "otherTypesMore_one": "他{{formatted}}件",
"otherTypesMore_other": "他{{formatted}}件",
"untyped": "種類なし",
"loadFailed": "グラフを読み込めませんでした。",
@@ -245,6 +247,7 @@
"showMore": "もっと見る",
"showLess": "表示を減らす",
"relationships": "関係",
+ "relationshipsCapped_one": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
"relationshipsCapped_other": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
"noRelationships": "このエンティティには関係がありません。",
"relatedTo": "関連",
@@ -253,8 +256,11 @@
"sourceChunks": "ソースチャンク",
"showInGraph": "グラフで表示",
"allTypes": "すべての種類",
+ "chunkCount_one": "{{formatted}}件のチャンク",
"chunkCount_other": "{{formatted}}件のチャンク",
+ "entityCount_one": "{{formatted}}件のエンティティ",
"entityCount_other": "{{formatted}}件のエンティティ",
+ "relationshipCount_one": "{{formatted}}件の関係",
"relationshipCount_other": "{{formatted}}件の関係",
"chunk": "チャンク",
"chunkMeta": "{{file}} · {{tokens}} トークン",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答がこのチャンクを引用するときの名前です。",
"files": "ファイル",
"filesLoadError": "ファイルを読み込めませんでした",
+ "filesByline_one": "{{files}} ファイル · {{tokens}} トークン",
"filesByline_other": "{{files}} ファイル · {{tokens}} トークン",
"filterFiles": "ファイルを絞り込む",
"searchChunks": "チャンクを検索",
+ "chunkCount_one": "{{formatted}} 件のチャンク",
"chunkCount_other": "{{formatted}} 件のチャンク",
"editor": {
"write": "編集",
@@ -667,9 +675,10 @@
"createTeamError": "チームを作成できませんでした。",
"noTeams": "チームはまだありません。",
"noDescription": "説明なし",
- "memberCountOne": "{{count}}人のメンバー",
- "memberCountOther": "{{count}}人のメンバー",
- "sharedCount": "{{count}}件の共有",
+ "memberCount_one": "{{formatted}}人のメンバー",
+ "memberCount_other": "{{formatted}}人のメンバー",
+ "sharedCount_one": "{{formatted}}件の共有",
+ "sharedCount_other": "{{formatted}}件の共有",
"loadError": "チームの読み込みに失敗しました。",
"roleAdmin": "管理者",
"roleMember": "メンバー",
@@ -737,8 +746,9 @@
"viaTeam": "{{team}}経由",
"removeAccess": "アクセス権を削除",
"access": "アクセス",
- "showAll": "すべて表示({{count}})",
- "andMore": "ほか {{count}} 件",
+ "showAll": "すべて表示({{formatted}})",
+ "andMore_one": "ほか {{formatted}} 件",
+ "andMore_other": "ほか {{formatted}} 件",
"back": "戻る",
"allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
"searchAccess": "ユーザーとチームを検索…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} 編集者",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} 編集者",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} 編集者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "共有リソースを絞り込む",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "公開済み"
+ },
+ "sponsors": {
+ "attachNote": "自分のライブラリから追加したツール、ソース、プロンプトは、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
+ "publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
+ "addedBy": "{{person}} が追加: {{names}}",
+ "unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。",
+ "unknownItem": "名前のない項目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "ToDo はまだありません"
},
"note": {
+ "lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空のメモ"
},
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index 1949563a..85b1af45 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -718,9 +718,14 @@
"createTeamError": "Не удалось создать команду.",
"noTeams": "Команд пока нет.",
"noDescription": "Без описания",
- "memberCountOne": "{{count}} участник",
- "memberCountOther": "Участников: {{count}}",
- "sharedCount": "Общих ресурсов: {{count}}",
+ "memberCount_one": "{{formatted}} участник",
+ "memberCount_few": "{{formatted}} участника",
+ "memberCount_many": "{{formatted}} участников",
+ "memberCount_other": "{{formatted}} участника",
+ "sharedCount_one": "{{formatted}} общий ресурс",
+ "sharedCount_few": "{{formatted}} общих ресурса",
+ "sharedCount_many": "{{formatted}} общих ресурсов",
+ "sharedCount_other": "{{formatted}} общих ресурса",
"loadError": "Не удалось загрузить команды.",
"roleAdmin": "администратор",
"roleMember": "участник",
@@ -788,8 +793,11 @@
"viaTeam": "через {{team}}",
"removeAccess": "Убрать доступ",
"access": "Доступ",
- "showAll": "Показать все ({{count}})",
- "andMore": "и ещё {{count}}",
+ "showAll": "Показать все ({{formatted}})",
+ "andMore_one": "и ещё {{formatted}}",
+ "andMore_few": "и ещё {{formatted}}",
+ "andMore_many": "и ещё {{formatted}}",
+ "andMore_other": "и ещё {{formatted}}",
"back": "Назад",
"allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
"searchAccess": "Поиск людей и команд…",
@@ -929,7 +937,10 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} ред.",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} редактор",
+ "badgeWithEditors_few": "{{level}} · +{{formatted}} редактора",
+ "badgeWithEditors_many": "{{level}} · +{{formatted}} редакторов",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} редактора",
"meta": "{{type}} · {{owner}}",
"filterLabel": "Фильтр общих ресурсов",
"filter": {
@@ -2071,6 +2082,13 @@
},
"status": {
"published": "Опубликован"
+ },
+ "sponsors": {
+ "attachNote": "Инструменты, источники и промпты, которые вы добавляете из своей библиотеки, работают с вашим доступом для всех, кто пользуется этим агентом.",
+ "publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
+ "addedBy": "Добавил(а) {{person}}: {{names}}",
+ "unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.",
+ "unknownItem": "Элемент без названия"
}
},
"logs": {
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index f89e7372..fc735107 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -176,6 +176,7 @@
}
},
"badge": "動態 Wiki",
+ "byline_one": "{{pages}} 個頁面 · {{tokens}} 個 token",
"byline_other": "{{pages}} 個頁面 · {{tokens}} 個 token",
"explainer": "代理在工作時會閱讀並改寫這些頁面。",
"explainerEditable": "代理在工作時會閱讀並改寫這些頁面;任何使用此來源的代理都可以編輯它。",
@@ -234,6 +235,7 @@
"byConnections": "(依連結數)",
"typeFilter": "依類型篩選",
"otherTypes": "其他:{{types}}",
+ "otherTypesMore_one": "另外 {{formatted}} 個",
"otherTypesMore_other": "另外 {{formatted}} 個",
"untyped": "無類型",
"loadFailed": "無法載入圖譜。",
@@ -245,6 +247,7 @@
"showMore": "顯示更多",
"showLess": "收合",
"relationships": "關係",
+ "relationshipsCapped_one": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
"relationshipsCapped_other": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
"noRelationships": "此實體沒有關係。",
"relatedTo": "相關",
@@ -253,8 +256,11 @@
"sourceChunks": "來源區塊",
"showInGraph": "在圖譜中顯示",
"allTypes": "所有類型",
+ "chunkCount_one": "{{formatted}} 個區塊",
"chunkCount_other": "{{formatted}} 個區塊",
+ "entityCount_one": "{{formatted}} 個實體",
"entityCount_other": "{{formatted}} 個實體",
+ "relationshipCount_one": "{{formatted}} 個關係",
"relationshipCount_other": "{{formatted}} 個關係",
"chunk": "區塊",
"chunkMeta": "{{file}} · {{tokens}} Token",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答引用此文本塊時使用的名稱。",
"files": "檔案",
"filesLoadError": "無法載入檔案",
+ "filesByline_one": "{{files}} 個檔案 · {{tokens}} Token",
"filesByline_other": "{{files}} 個檔案 · {{tokens}} Token",
"filterFiles": "篩選檔案",
"searchChunks": "搜尋文本塊",
+ "chunkCount_one": "{{formatted}} 個文本塊",
"chunkCount_other": "{{formatted}} 個文本塊",
"editor": {
"write": "編輯",
@@ -667,9 +675,10 @@
"createTeamError": "無法建立團隊。",
"noTeams": "尚無團隊。",
"noDescription": "無描述",
- "memberCountOne": "{{count}} 位成員",
- "memberCountOther": "{{count}} 位成員",
- "sharedCount": "已分享 {{count}} 項",
+ "memberCount_one": "{{formatted}} 位成員",
+ "memberCount_other": "{{formatted}} 位成員",
+ "sharedCount_one": "已分享 {{formatted}} 項",
+ "sharedCount_other": "已分享 {{formatted}} 項",
"loadError": "載入團隊失敗。",
"roleAdmin": "管理員",
"roleMember": "成員",
@@ -737,8 +746,9 @@
"viaTeam": "透過 {{team}}",
"removeAccess": "移除存取權",
"access": "存取權",
- "showAll": "顯示全部 {{count}} 個",
- "andMore": "還有 {{count}} 個",
+ "showAll": "顯示全部 {{formatted}} 個",
+ "andMore_one": "還有 {{formatted}} 個",
+ "andMore_other": "還有 {{formatted}} 個",
"back": "返回",
"allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
"searchAccess": "搜尋人員和團隊…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} 編輯者",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} 編輯者",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} 編輯者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "篩選共用資源",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "已發佈"
+ },
+ "sponsors": {
+ "attachNote": "你從自己的資料庫新增的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。",
+ "publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
+ "addedBy": "由 {{person}} 新增:{{names}}",
+ "unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。",
+ "unknownItem": "未命名項目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "尚無待辦事項"
},
"note": {
+ "lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空白筆記"
},
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 8163533b..393cb5d2 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -176,6 +176,7 @@
}
},
"badge": "动态 Wiki",
+ "byline_one": "{{pages}} 个页面 · {{tokens}} 个 token",
"byline_other": "{{pages}} 个页面 · {{tokens}} 个 token",
"explainer": "代理在工作时会阅读并改写这些页面。",
"explainerEditable": "代理在工作时会阅读并改写这些页面;任何使用此来源的代理都可以编辑它。",
@@ -234,6 +235,7 @@
"byConnections": "(按连接数)",
"typeFilter": "按类型筛选",
"otherTypes": "其他:{{types}}",
+ "otherTypesMore_one": "另外 {{formatted}} 个",
"otherTypesMore_other": "另外 {{formatted}} 个",
"untyped": "无类型",
"loadFailed": "无法加载图谱。",
@@ -245,6 +247,7 @@
"showMore": "显示更多",
"showLess": "收起",
"relationships": "关系",
+ "relationshipsCapped_one": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
"relationshipsCapped_other": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
"noRelationships": "此实体没有关系。",
"relatedTo": "相关",
@@ -253,8 +256,11 @@
"sourceChunks": "来源分块",
"showInGraph": "在图谱中显示",
"allTypes": "所有类型",
+ "chunkCount_one": "{{formatted}} 个分块",
"chunkCount_other": "{{formatted}} 个分块",
+ "entityCount_one": "{{formatted}} 个实体",
"entityCount_other": "{{formatted}} 个实体",
+ "relationshipCount_one": "{{formatted}} 个关系",
"relationshipCount_other": "{{formatted}} 个关系",
"chunk": "分块",
"chunkMeta": "{{file}} · {{tokens}} 个令牌",
@@ -420,9 +426,11 @@
"chunkTitleHint": "回答引用此文本块时使用的名称。",
"files": "文件",
"filesLoadError": "无法加载文件",
+ "filesByline_one": "{{files}} 个文件 · {{tokens}} 个令牌",
"filesByline_other": "{{files}} 个文件 · {{tokens}} 个令牌",
"filterFiles": "筛选文件",
"searchChunks": "搜索文本块",
+ "chunkCount_one": "{{formatted}} 个文本块",
"chunkCount_other": "{{formatted}} 个文本块",
"editor": {
"write": "编辑",
@@ -667,9 +675,10 @@
"createTeamError": "无法创建团队。",
"noTeams": "暂无团队。",
"noDescription": "无描述",
- "memberCountOne": "{{count}} 名成员",
- "memberCountOther": "{{count}} 名成员",
- "sharedCount": "{{count}} 项共享",
+ "memberCount_one": "{{formatted}} 名成员",
+ "memberCount_other": "{{formatted}} 名成员",
+ "sharedCount_one": "{{formatted}} 项共享",
+ "sharedCount_other": "{{formatted}} 项共享",
"loadError": "加载团队失败。",
"roleAdmin": "管理员",
"roleMember": "成员",
@@ -737,8 +746,9 @@
"viaTeam": "通过 {{team}}",
"removeAccess": "移除访问权限",
"access": "访问权限",
- "showAll": "显示全部 {{count}} 个",
- "andMore": "还有 {{count}} 个",
+ "showAll": "显示全部 {{formatted}} 个",
+ "andMore_one": "还有 {{formatted}} 个",
+ "andMore_other": "还有 {{formatted}} 个",
"back": "返回",
"allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
"searchAccess": "搜索人员和团队…",
@@ -878,7 +888,8 @@
}
},
"sharedList": {
- "badgeWithEditors": "{{level}} · +{{count}} 编辑者",
+ "badgeWithEditors_one": "{{level}} · +{{formatted}} 编辑者",
+ "badgeWithEditors_other": "{{level}} · +{{formatted}} 编辑者",
"meta": "{{type}} · {{owner}}",
"filterLabel": "筛选共享资源",
"filter": {
@@ -1994,6 +2005,13 @@
},
"status": {
"published": "已发布"
+ },
+ "sponsors": {
+ "attachNote": "你从自己的资料库添加的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。",
+ "publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
+ "addedBy": "由 {{person}} 添加:{{names}}",
+ "unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。",
+ "unknownItem": "未命名项目"
}
},
"logs": {
@@ -2455,6 +2473,7 @@
"empty": "暂无待办事项"
},
"note": {
+ "lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"empty": "空笔记"
},
diff --git a/frontend/src/settings/Teams.test.tsx b/frontend/src/settings/Teams.test.tsx
index b9d4ed2c..218403a7 100644
--- a/frontend/src/settings/Teams.test.tsx
+++ b/frontend/src/settings/Teams.test.tsx
@@ -223,7 +223,7 @@ describe('Teams page', () => {
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('Key Accounts');
expect(rows[0].textContent).toContain(
- 'settings.teams.sharedList.badgeWithEditors(level=viewer,count=#1)',
+ 'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1,formatted=#1)',
);
expect(rows[0].textContent).toContain(
'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)',
diff --git a/frontend/src/settings/Teams.tsx b/frontend/src/settings/Teams.tsx
index b897d190..ddc8f203 100644
--- a/frontend/src/settings/Teams.tsx
+++ b/frontend/src/settings/Teams.tsx
@@ -697,7 +697,8 @@ export default function Teams() {
? t('settings.teams.sharedList.badgeWithEditors', {
interpolation: { escapeValue: false },
level,
- count: formatCount(memberEditors),
+ count: memberEditors,
+ formatted: formatCount(memberEditors),
})
: level;
}
@@ -863,17 +864,16 @@ export default function Teams() {
- {t(
- (team.member_count ?? 0) === 1
- ? 'settings.teams.memberCountOne'
- : 'settings.teams.memberCountOther',
- { count: formatCount(team.member_count ?? 0) },
- )}
+ {t('settings.teams.memberCount', {
+ count: team.member_count ?? 0,
+ formatted: formatCount(team.member_count ?? 0),
+ })}
·
{t('settings.teams.sharedCount', {
- count: formatCount(team.shared_count ?? 0),
+ count: team.shared_count ?? 0,
+ formatted: formatCount(team.shared_count ?? 0),
})}
diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx
index ef6d30ac..e2c4fb77 100644
--- a/frontend/src/teams/ShareToTeamModal.test.tsx
+++ b/frontend/src/teams/ShareToTeamModal.test.tsx
@@ -259,10 +259,12 @@ describe('ShareToTeamModal', () => {
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)');
+ expect(text()).toContain(
+ 'settings.teams.share.andMore(count=5,formatted=#5)',
+ );
const showAll = buttonByText('settings.teams.share.showAll');
expect(showAll?.textContent).toContain(
- 'settings.teams.share.showAll(count=#8)',
+ 'settings.teams.share.showAll(formatted=#8)',
);
expect(showAll?.getAttribute('data-variant')).toBe('link');
expect(body().querySelector('.max-h-72')).toBeNull();
diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx
index ac875b3c..6ead1a0f 100644
--- a/frontend/src/teams/ShareToTeamModal.tsx
+++ b/frontend/src/teams/ShareToTeamModal.tsx
@@ -934,7 +934,7 @@ export default function ShareToTeamModal({
onClick={openAllStep}
>
{t('settings.teams.share.showAll', {
- count: formatCount(shares.length),
+ formatted: formatCount(shares.length),
})}
@@ -948,7 +948,8 @@ export default function ShareToTeamModal({
{isLongList && (
{t('settings.teams.share.andMore', {
- count: formatCount(shares.length - previewShares.length),
+ count: shares.length - previewShares.length,
+ formatted: formatCount(shares.length - previewShares.length),
})}
)}
diff --git a/tests/api/test_agent_team_sharing.py b/tests/api/test_agent_team_sharing.py
index 624bfcc0..9a264ca4 100644
--- a/tests/api/test_agent_team_sharing.py
+++ b/tests/api/test_agent_team_sharing.py
@@ -195,6 +195,9 @@ def _update_patches(sub, repo, team_access, can_access_mock):
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
*_access_patches(sub, repo, team_access),
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
+ # Sponsor bookkeeping queries the (mocked) connection; covered with a
+ # real database in tests/api/user/test_resource_sponsors.py.
+ patch("docsgpt.api.user.agents.routes.sponsors_after_save", return_value={}),
]
diff --git a/tests/api/user/test_resource_sponsors.py b/tests/api/user/test_resource_sponsors.py
new file mode 100644
index 00000000..7dff59f6
--- /dev/null
+++ b/tests/api/user/test_resource_sponsors.py
@@ -0,0 +1,459 @@
+"""Resource sponsors: an editor's own tool/prompt/source runs inside the owner's agent.
+
+An agent runs as its owner, so a resource the owner can't use used to be
+dropped at run time even though an editor attached it. The editor who
+attaches it becomes its sponsor (``resource_sponsors``); the resource then
+runs while the sponsor can still edit the agent and still use the resource.
+Uses real repositories on ``pg_conn``.
+"""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import ExitStack, contextmanager
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+
+from docsgpt.api.user.resource_access import (
+ active_sponsor,
+ agent_refs,
+ ref_principal,
+ set_settings,
+ sponsor_key,
+ sponsors_after_save,
+)
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.sources import SourcesRepository
+from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.teams import TeamsRepository
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+
+OWNER, EDITOR, VIEWER, OTHER = "sp-owner", "sp-editor", "sp-viewer", "sp-other"
+
+_DB_MODULES = (
+ "docsgpt.api.user.agents.routes",
+ "docsgpt.api.user.workflows.routes",
+ "docsgpt.api.user.base",
+ "docsgpt.agents.tool_executor",
+ "docsgpt.api.answer.services.stream_processor",
+ "docsgpt.agents.workflows.workflow_engine",
+ "docsgpt.storage.db.session",
+)
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ import importlib
+
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with ExitStack() as stack:
+ for mod_name in _DB_MODULES:
+ mod = importlib.import_module(mod_name)
+ for attr in ("db_session", "db_readonly"):
+ if hasattr(mod, attr):
+ stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
+ yield
+
+
+def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
+ kwargs = {"method": method.upper()}
+ if json is not None:
+ kwargs["json"] = json
+ with _patch_db(conn), app.test_request_context(path, **kwargs):
+ from flask import request
+
+ request.decoded_token = {"sub": user}
+ return getattr(resource_cls(), method.lower())(*args)
+
+
+def _status(resp) -> int:
+ return resp[1] if isinstance(resp, tuple) else resp.status_code
+
+
+def _share_agent(conn, agent_id):
+ """Share the agent with a team: EDITOR as editor, VIEWER as viewer."""
+ team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", OWNER)
+ tid = str(team["id"])
+ for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
+ TeamMembersRepository(conn).add_member(tid, member)
+ TeamResourceGrantsRepository(conn).grant(
+ tid, "agent", agent_id, OWNER, OWNER, access_level=level, target_user_id=member
+ )
+ return tid
+
+
+def _agent(conn, **extra):
+ extra.setdefault("agent_type", "classic")
+ extra.setdefault("chunks", 6)
+ row = AgentsRepository(conn).create(
+ OWNER, "Shared", "published", description="d", key=f"k-{uuid.uuid4().hex}", **extra
+ )
+ agent_id = str(row["id"])
+ team_id = _share_agent(conn, agent_id)
+ return agent_id, team_id
+
+
+def _row(conn, agent_id):
+ return AgentsRepository(conn).get_by_id(agent_id)
+
+
+def _editor_resources(conn):
+ tool = str(UserToolsRepository(conn).create(EDITOR, "api_tool")["id"])
+ prompt = str(PromptsRepository(conn).create(EDITOR, "mine", "Editor prompt")["id"])
+ source = str(SourcesRepository(conn).create("editor-src", user_id=EDITOR)["id"])
+ return tool, prompt, source
+
+
+def _put(app, conn, agent_id, user, body):
+ from docsgpt.api.user.agents.routes import UpdateAgent
+
+ return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}", user,
+ json=body, args=(agent_id,))
+
+
+# ---------------------------------------------------------------------------
+# sponsors_after_save
+# ---------------------------------------------------------------------------
+
+
+class TestSponsorsAfterSave:
+ def test_owner_usable_refs_need_no_sponsor(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
+ out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, [("tool", tool)])
+ assert out == {}
+
+ def test_editor_resources_are_sponsored_by_editor(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, prompt, source = _editor_resources(pg_conn)
+ refs = [("tool", tool), ("prompt", prompt), ("source", source)]
+ out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs)
+ assert out == {sponsor_key(t, i): EDITOR for t, i in refs}
+
+ def test_builtin_tool_and_preset_ids_are_skipped(self, pg_conn):
+ from docsgpt.agents.default_tools import loaded_builtin_agent_tools, synthesize_builtin_agent_tool
+
+ agent_id, _ = _agent(pg_conn)
+ builtin = next(iter(loaded_builtin_agent_tools()), None)
+ refs = [("prompt", "default")]
+ if builtin:
+ refs.append(("tool", str(synthesize_builtin_agent_tool(builtin)["id"])))
+ assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs) == {}
+
+ def test_owner_save_keeps_editor_sponsor(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
+ assert out == {sponsor_key("tool", tool): EDITOR}
+
+ def test_removed_ref_drops_out(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, []) == {}
+
+ def test_another_editor_takes_over_when_sponsor_lost_access(self, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ # A tool shared with the whole team: both editors may use it in their agents.
+ tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
+ TeamMembersRepository(pg_conn).add_member(team_id, OTHER)
+ TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OTHER, OTHER)
+ TeamResourceGrantsRepository(pg_conn).grant(
+ team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=OTHER
+ )
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
+
+ out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OTHER, [("tool", tool)])
+ assert out == {sponsor_key("tool", tool): OTHER}
+
+ def test_dead_sponsor_record_kept_when_nobody_qualifies(self, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
+ out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
+ assert out == {sponsor_key("tool", tool): EDITOR}
+
+
+# ---------------------------------------------------------------------------
+# active_sponsor / ref_principal (live checks)
+# ---------------------------------------------------------------------------
+
+
+class TestActiveSponsor:
+ def _sponsored(self, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ return agent_id, team_id, tool
+
+ def test_live_sponsor_is_principal(self, pg_conn):
+ agent_id, _, tool = self._sponsored(pg_conn)
+ agent = _row(pg_conn, agent_id)
+ assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == EDITOR
+ assert ref_principal(pg_conn, "agent", agent, "tool", tool) == EDITOR
+
+ def test_owner_wins_over_sponsor(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == OWNER
+
+ def test_sponsor_removed_from_agent_stops(self, pg_conn):
+ agent_id, team_id, tool = self._sponsored(pg_conn)
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
+ assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
+
+ def test_sponsor_demoted_to_viewer_stops(self, pg_conn):
+ agent_id, team_id, tool = self._sponsored(pg_conn)
+ TeamResourceGrantsRepository(pg_conn).grant(
+ team_id, "agent", agent_id, OWNER, OWNER, access_level="viewer", target_user_id=EDITOR
+ )
+ assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
+
+ def test_sponsor_losing_the_resource_stops(self, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
+ TeamResourceGrantsRepository(pg_conn).grant(
+ team_id, "tool", tool, OTHER, OTHER, target_user_id=EDITOR
+ )
+ AgentsRepository(pg_conn).update_by_id(
+ agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
+ )
+ assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == EDITOR
+ set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OTHER)
+ assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
+
+ def test_no_record_no_sponsor(self, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
+
+
+# ---------------------------------------------------------------------------
+# update_agent / get_agent
+# ---------------------------------------------------------------------------
+
+
+class TestAgentRoutes:
+ def test_editor_attaching_own_resources_records_sponsor(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, prompt, source = _editor_resources(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR,
+ {"tools": [tool], "prompt_id": prompt, "source": source})
+ assert _status(resp) == 200
+ row = _row(pg_conn, agent_id)
+ assert row["resource_sponsors"] == {
+ sponsor_key("tool", tool): EDITOR,
+ sponsor_key("prompt", prompt): EDITOR,
+ sponsor_key("source", source): EDITOR,
+ }
+ assert set(agent_refs(row)) >= {("tool", tool), ("prompt", prompt), ("source", source)}
+
+ def test_owner_save_does_not_wipe_sponsors(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
+ assert _status(_put(app, pg_conn, agent_id, OWNER, {"name": "Renamed", "tools": [tool]})) == 200
+ assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
+
+ def test_detaching_clears_sponsor(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
+ assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": []})) == 200
+ assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
+
+ def test_get_agent_lists_sponsors_for_editors_only(self, app, pg_conn):
+ from docsgpt.api.user.agents.routes import GetAgent
+ from docsgpt.storage.db.repositories.users import UsersRepository
+
+ agent_id, team_id = _agent(pg_conn)
+ tool, _, _ = _editor_resources(pg_conn)
+ UsersRepository(pg_conn).upsert(EDITOR, email="bob@example.com")
+ assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
+
+ path = f"/api/get_agent?id={agent_id}"
+ owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
+ assert owner_view["resource_sponsors"] == [
+ {"type": "tool", "id": tool, "user_id": EDITOR, "label": "bob@example.com", "active": True}
+ ]
+ viewer_view = _call(app, pg_conn, GetAgent, "get", path, VIEWER).get_json()
+ assert viewer_view["resource_sponsors"] == []
+
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
+ owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
+ assert owner_view["resource_sponsors"][0]["active"] is False
+
+
+# ---------------------------------------------------------------------------
+# run time
+# ---------------------------------------------------------------------------
+
+
+class TestRunTime:
+ def _sponsored_agent(self, app, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool, prompt, source = _editor_resources(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR,
+ {"tools": [tool], "prompt_id": prompt, "source": source})
+ assert _status(resp) == 200
+ return _row(pg_conn, agent_id), team_id, tool, prompt, source
+
+ def test_agent_key_toolset_includes_sponsored_tool(self, app, pg_conn):
+ from docsgpt.agents.tool_executor import ToolExecutor
+
+ agent, team_id, tool, _, _ = self._sponsored_agent(app, pg_conn)
+ with _patch_db(pg_conn):
+ tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
+ assert tool in tools
+ # The row is the tool owner's, so its credentials decrypt as the editor.
+ assert tools[tool]["user_id"] == EDITOR
+
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
+ with _patch_db(pg_conn):
+ tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
+ assert tool not in tools
+
+ def test_sponsored_prompt_renders(self, app, pg_conn):
+ from docsgpt.api.answer.services.stream_processor import authorized_prompt_id
+
+ agent, team_id, _, prompt, _ = self._sponsored_agent(app, pg_conn)
+ with _patch_db(pg_conn):
+ assert authorized_prompt_id(prompt, OWNER, agent) == prompt
+ assert authorized_prompt_id(prompt, OWNER) == "default"
+ TeamResourceGrantsRepository(pg_conn).revoke(
+ team_id, "agent", str(agent["id"]), target_user_id=EDITOR
+ )
+ assert authorized_prompt_id(prompt, OWNER, agent) == "default"
+
+ def test_agent_sources_include_sponsored_and_team_shared(self, app, pg_conn):
+ from docsgpt.api.answer.services.stream_processor import StreamProcessor
+
+ agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
+ # A source shared with the owner by a team (not owned): used to be
+ # dropped by an owner-scoped read.
+ shared = str(SourcesRepository(pg_conn).create("shared-src", user_id=OTHER)["id"])
+ if not TeamMembersRepository(pg_conn).is_member(OWNER, team_id):
+ TeamMembersRepository(pg_conn).add_member(team_id, OWNER)
+ TeamResourceGrantsRepository(pg_conn).grant(team_id, "source", shared, OTHER, OTHER)
+ AgentsRepository(pg_conn).update_by_id(str(agent["id"]), {"extra_source_ids": [shared]})
+
+ processor = StreamProcessor.__new__(StreamProcessor)
+ with _patch_db(pg_conn):
+ data = processor._get_data_from_api_key(agent["key"])
+ assert [s["id"] for s in data["sources"]] == [source, shared]
+
+ def test_search_service_authorizes_sponsored_source(self, app, pg_conn):
+ from docsgpt.services.search_service import _authorized_source_ids
+
+ agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
+ stranger_src = str(SourcesRepository(pg_conn).create("x", user_id="sp-stranger")["id"])
+ assert _authorized_source_ids(pg_conn, agent, [source, stranger_src]) == [source]
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
+ assert _authorized_source_ids(pg_conn, agent, [source]) == []
+
+
+# ---------------------------------------------------------------------------
+# workflows
+# ---------------------------------------------------------------------------
+
+
+def _wf_body(tool=None, source=None):
+ agent_cfg = {"agent_type": "classic", "system_prompt": "s", "tools": [tool] if tool else [],
+ "sources": [source] if source else []}
+ return {
+ "name": "WF",
+ "description": "d",
+ "nodes": [
+ {"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
+ {"id": "a1", "type": "agent", "title": "A", "position": {"x": 50, "y": 0},
+ "data": {"config": agent_cfg}},
+ {"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
+ ],
+ "edges": [
+ {"id": "e1", "source": "start1", "target": "a1"},
+ {"id": "e2", "source": "a1", "target": "end1"},
+ ],
+ }
+
+
+class TestWorkflows:
+ def _setup(self, pg_conn):
+ wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
+ _, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
+ return str(wf["id"]), team_id
+
+ def _put(self, app, pg_conn, wid, user, body):
+ from docsgpt.api.user.workflows.routes import WorkflowDetail
+
+ return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user,
+ json=body, args=(wid,))
+
+ def test_editor_node_resources_are_sponsored(self, app, pg_conn):
+ wid, _ = self._setup(pg_conn)
+ tool, _, source = _editor_resources(pg_conn)
+ resp = self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))
+ assert _status(resp) == 200, resp.get_json()
+ row = WorkflowsRepository(pg_conn).get_by_id(wid)
+ assert row["resource_sponsors"] == {
+ sponsor_key("tool", tool): EDITOR,
+ sponsor_key("source", source): EDITOR,
+ }
+ # Removing the node's refs clears them.
+ assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body())) == 200
+ assert WorkflowsRepository(pg_conn).get_by_id(wid)["resource_sponsors"] == {}
+
+ def test_engine_resolves_sponsored_node_refs(self, app, pg_conn):
+ from docsgpt.agents.workflows.workflow_engine import WorkflowEngine
+
+ wid, team_id = self._setup(pg_conn)
+ tool, _, source = _editor_resources(pg_conn)
+ assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))) == 200
+
+ engine = WorkflowEngine.__new__(WorkflowEngine)
+ engine.agent = SimpleNamespace(
+ workflow_row=WorkflowsRepository(pg_conn).get_by_id(wid),
+ _resolve_owner_id=lambda: OWNER,
+ user=OWNER,
+ decoded_token={"sub": OWNER},
+ )
+ with _patch_db(pg_conn):
+ assert engine._node_tool_principals([tool]) == {tool: EDITOR}
+ assert engine._authorized_node_sources([source]) == [source]
+ agent_id = str(pg_conn.exec_driver_sql(
+ f"SELECT id FROM agents WHERE workflow_id = '{wid}'"
+ ).scalar())
+ TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
+ assert engine._node_tool_principals([tool]) == {}
+ assert engine._authorized_node_sources([source]) == []
diff --git a/tests/services/test_search_service.py b/tests/services/test_search_service.py
index a1cbe6f7..d51532e4 100644
--- a/tests/services/test_search_service.py
+++ b/tests/services/test_search_service.py
@@ -31,7 +31,7 @@ def _fake_db_readonly(agent_data):
yield MagicMock()
with patch(
- "docsgpt.api.user.team_sharing.can_access", return_value=True
+ "docsgpt.api.user.resource_access.ref_principal", return_value="owner"
), patch(
"docsgpt.services.search_service.db_readonly", _yield_conn
), patch(
@@ -254,24 +254,24 @@ class TestSourceAuthorization:
return {"id": "agent-1", "user_id": "owner", "extra_source_ids": [], **kw}
def test_readable_sources_pass_through(self, monkeypatch):
- import docsgpt.api.user.team_sharing as ts
+ import docsgpt.api.user.resource_access as ra
- monkeypatch.setattr(ts, "can_access", lambda *a, **k: True)
+ monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner")
agent = self._agent(extra_source_ids=["s1", "s2"])
assert _authorized_source_ids(None, agent, ["s1", "s2"]) == ["s1", "s2"]
def test_foreign_source_is_dropped(self, monkeypatch):
- import docsgpt.api.user.team_sharing as ts
+ import docsgpt.api.user.resource_access as ra
- monkeypatch.setattr(ts, "can_access", lambda conn, k, sid, u: sid == "mine")
+ monkeypatch.setattr(ra, "ref_principal", lambda conn, h, agent, k, sid: "owner" if sid == "mine" else None)
agent = self._agent()
assert _authorized_source_ids(None, agent, ["mine", "theirs"]) == ["mine"]
def test_team_shared_source_is_kept(self, monkeypatch):
"""A grant is legitimate access; only unreadable ids are dropped."""
- import docsgpt.api.user.team_sharing as ts
+ import docsgpt.api.user.resource_access as ra
- monkeypatch.setattr(ts, "can_access", lambda *a, **k: True)
+ monkeypatch.setattr(ra, "ref_principal", lambda *a, **k: "owner")
agent = self._agent(user_id="grantee")
assert _authorized_source_ids(None, agent, ["shared"]) == ["shared"]
@@ -280,12 +280,12 @@ class TestSourceAuthorization:
assert _authorized_source_ids(None, agent, ["s1"]) == []
def test_check_failure_fails_closed(self, monkeypatch):
- import docsgpt.api.user.team_sharing as ts
+ import docsgpt.api.user.resource_access as ra
def _boom(*a, **k):
raise RuntimeError("db down")
- monkeypatch.setattr(ts, "can_access", _boom)
+ monkeypatch.setattr(ra, "ref_principal", _boom)
assert _authorized_source_ids(None, self._agent(), ["s1"]) == []
diff --git a/tests/tracing/test_entry_points.py b/tests/tracing/test_entry_points.py
index 5a613892..218432d2 100644
--- a/tests/tracing/test_entry_points.py
+++ b/tests/tracing/test_entry_points.py
@@ -149,7 +149,7 @@ def _search_env(agent):
store = MagicMock()
store.search.return_value = [{"text": "hit", "metadata": {"title": "T", "source": "s"}}]
- with patch("docsgpt.api.user.team_sharing.can_access", return_value=True), \
+ with patch("docsgpt.api.user.resource_access.ref_principal", return_value="owner"), \
patch("docsgpt.services.search_service.db_readonly", _conn), \
patch("docsgpt.services.search_service.AgentsRepository", return_value=repo), \
patch(
From 49a3a2a220f71cf541c7427d267e9074d91419a7 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 12:19:06 +0400
Subject: [PATCH 5/9] fix batch 1
---
docsgpt/storage/db/repositories/agents.py | 2 +-
.../SponsoredResourcesNotice.test.tsx | 33 +++++++++++++++++++
.../components/SponsoredResourcesNotice.tsx | 10 ++++--
frontend/src/teams/accessSettings.ts | 15 ++++++---
frontend/src/teams/teamsSlice.test.ts | 28 ++++++++++++++++
tests/storage/db/repositories/test_agents.py | 6 ++++
6 files changed, 87 insertions(+), 7 deletions(-)
diff --git a/docsgpt/storage/db/repositories/agents.py b/docsgpt/storage/db/repositories/agents.py
index 78c2bb80..8676966b 100644
--- a/docsgpt/storage/db/repositories/agents.py
+++ b/docsgpt/storage/db/repositories/agents.py
@@ -43,7 +43,7 @@ class AgentsRepository:
"limited_token_mode", "limited_request_mode",
"allow_system_prompt_override",
"shared", "shared_token", "shared_metadata",
- "tools", "json_schema", "models", "config", "legacy_mongo_id",
+ "tools", "json_schema", "models", "config", "resource_sponsors", "legacy_mongo_id",
"created_at", "updated_at", "last_used_at",
}
diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
index fa3a510f..ef92086e 100644
--- a/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
+++ b/frontend/src/agents/components/SponsoredResourcesNotice.test.tsx
@@ -1,6 +1,9 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
import type { Agent, ResourceSponsor } from '../types';
import SponsoredResourcesNotice from './SponsoredResourcesNotice';
@@ -86,6 +89,36 @@ describe('SponsoredResourcesNotice', () => {
expect(container.innerHTML).toBe('');
});
+ it('joins names for the app language and does not escape them', async () => {
+ await i18n.use(initReactI18next).init({
+ lng: 'jp',
+ resources: {
+ jp: {
+ translation: {
+ agents: {
+ form: { sponsors: { addedBy: '{{person}}: {{names}}' } },
+ },
+ },
+ },
+ },
+ });
+ try {
+ await render({
+ ...baseAgent,
+ resource_sponsors: [
+ sponsor({ id: 'docs/a' }),
+ sponsor({ id: 'docs/b' }),
+ ],
+ });
+ const expected = new Intl.ListFormat('ja', {
+ type: 'conjunction',
+ }).format(['name-docs/a', 'name-docs/b']);
+ expect(container.textContent).toBe(`bob@example.com: ${expected}`);
+ } finally {
+ await i18n.changeLanguage('en');
+ }
+ });
+
it('splits running and no-longer-running items', async () => {
await render({
...baseAgent,
diff --git a/frontend/src/agents/components/SponsoredResourcesNotice.tsx b/frontend/src/agents/components/SponsoredResourcesNotice.tsx
index 79d8fd26..42c6c687 100644
--- a/frontend/src/agents/components/SponsoredResourcesNotice.tsx
+++ b/frontend/src/agents/components/SponsoredResourcesNotice.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { can, isOwner } from '../../utils/accessUtils';
+import { intlLocale } from '../../utils/dateTimeUtils';
import type { Agent, ResourceSponsor } from '../types';
type SponsoredResourcesNoticeProps = {
@@ -34,7 +35,7 @@ export default function SponsoredResourcesNotice({
agent,
resolveName,
}: SponsoredResourcesNoticeProps) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const sponsors = agent.resource_sponsors ?? [];
const showAttachNote = !isOwner(agent) && can(agent, 'edit');
const active = groupByPerson(sponsors.filter((s) => s.active));
@@ -42,8 +43,11 @@ export default function SponsoredResourcesNotice({
if (!showAttachNote && sponsors.length === 0) return null;
+ const listFormat = new Intl.ListFormat(intlLocale(i18n.language), {
+ type: 'conjunction',
+ });
const names = (items: ResourceSponsor[]) =>
- items.map((item) => resolveName(item)).join(', ');
+ listFormat.format(items.map((item) => resolveName(item)));
return (
@@ -63,6 +67,7 @@ export default function SponsoredResourcesNotice({
{active.map(([person, items]) => (
{t('agents.form.sponsors.addedBy', {
+ interpolation: { escapeValue: false },
person,
names: names(items),
})}
@@ -78,6 +83,7 @@ export default function SponsoredResourcesNotice({
{inactive.map(([person, items]) => (
{t('agents.form.sponsors.unavailable', {
+ interpolation: { escapeValue: false },
person,
names: names(items),
})}
diff --git a/frontend/src/teams/accessSettings.ts b/frontend/src/teams/accessSettings.ts
index 02caa3c4..46306127 100644
--- a/frontend/src/teams/accessSettings.ts
+++ b/frontend/src/teams/accessSettings.ts
@@ -157,8 +157,15 @@ export function capabilityLines(
}
// 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
+// failure) falls back to the caller's generic copy. Matched by shape, not
+// `instanceof`: a thunk's unwrap() rethrows a serialized plain object.
+export const errorMessage = (error: unknown, fallback: string): string => {
+ const e = error as { name?: unknown; message?: unknown } | null;
+ return typeof e === 'object' &&
+ e !== null &&
+ e.name === 'TeamsApiError' &&
+ typeof e.message === 'string' &&
+ e.message
+ ? e.message
: fallback;
+};
diff --git a/frontend/src/teams/teamsSlice.test.ts b/frontend/src/teams/teamsSlice.test.ts
index ac4f6ffa..03897815 100644
--- a/frontend/src/teams/teamsSlice.test.ts
+++ b/frontend/src/teams/teamsSlice.test.ts
@@ -10,6 +10,7 @@ vi.mock('../api/services/teamsService', () => ({
import { configureStore } from '@reduxjs/toolkit';
+import { errorMessage } from './accessSettings';
import reducer, { deleteTeam, loadTeams, Team } from './teamsSlice';
const team: Team = { id: 't1', name: 'Ops', slug: 'ops', owner_id: 'u1' };
@@ -41,6 +42,33 @@ describe('teamsSlice', () => {
expect(store.getState().teams.teams).toHaveLength(1);
});
+ it("surfaces the server's reason through unwrap()", async () => {
+ // unwrap() rethrows a serialized plain object, not the Error instance.
+ remove.mockRejectedValue(
+ Object.assign(new Error('Only the owner can delete'), {
+ name: 'TeamsApiError',
+ }),
+ );
+ const store = makeStore();
+ const rejection = await store
+ .dispatch(deleteTeam({ id: 't1', token: null }))
+ .unwrap()
+ .catch((error: unknown) => error);
+ expect(errorMessage(rejection, 'fallback')).toBe(
+ 'Only the owner can delete',
+ );
+ });
+
+ it('keeps the fallback for a non-teams rejection', async () => {
+ remove.mockRejectedValue(new Error('network down'));
+ const store = makeStore();
+ const rejection = await store
+ .dispatch(deleteTeam({ id: 't1', token: null }))
+ .unwrap()
+ .catch((error: unknown) => error);
+ expect(errorMessage(rejection, 'fallback')).toBe('fallback');
+ });
+
it('keeps the team when a 2xx body says success:false', async () => {
remove.mockResolvedValue({ success: false, message: 'nope' });
const store = makeStore();
diff --git a/tests/storage/db/repositories/test_agents.py b/tests/storage/db/repositories/test_agents.py
index df9ea870..a6d9e8a2 100644
--- a/tests/storage/db/repositories/test_agents.py
+++ b/tests/storage/db/repositories/test_agents.py
@@ -49,6 +49,12 @@ class TestCreate:
)
assert doc["legacy_mongo_id"] == "507f1f77bcf86cd799439011"
+ def test_create_with_resource_sponsors(self, pg_conn):
+ repo = _repo(pg_conn)
+ sponsors = {"tool:t1": "editor-1"}
+ doc = repo.create("u", "a", "draft", resource_sponsors=sponsors)
+ assert doc["resource_sponsors"] == sponsors
+
def test_create_normalizes_blank_key_to_null(self, pg_conn):
repo = _repo(pg_conn)
doc = repo.create("u", "a", "draft", key="")
From 0622883ce498e440db19284ba90346b67666aada Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 12:23:12 +0400
Subject: [PATCH 6/9] fixes rabbit
---
frontend/src/settings/Prompts.test.tsx | 20 +++++++++++++
frontend/src/settings/Prompts.tsx | 6 +---
frontend/tsconfig.node.tsbuildinfo | 1 +
frontend/tsconfig.tsbuildinfo | 1 +
frontend/vite.config.d.ts | 2 ++
frontend/vite.config.js | 41 ++++++++++++++++++++++++++
6 files changed, 66 insertions(+), 5 deletions(-)
create mode 100644 frontend/tsconfig.node.tsbuildinfo
create mode 100644 frontend/tsconfig.tsbuildinfo
create mode 100644 frontend/vite.config.d.ts
create mode 100644 frontend/vite.config.js
diff --git a/frontend/src/settings/Prompts.test.tsx b/frontend/src/settings/Prompts.test.tsx
index 6815ca8b..d5105dde 100644
--- a/frontend/src/settings/Prompts.test.tsx
+++ b/frontend/src/settings/Prompts.test.tsx
@@ -347,6 +347,26 @@ describe('Prompts', () => {
expect(lastModalProps().readOnly).toBe(true);
});
+ it("keeps the server's actions for a selected prompt missing from the list", async () => {
+ getSinglePrompt.mockReturnValue(json({ content: 'Hello' }));
+ const unlisted = {
+ id: 'ul',
+ name: 'Unlisted prompt',
+ type: 'team',
+ access: 'editor' as const,
+ allowed_actions: ['edit', 'use'],
+ };
+ renderPrompts({ prompts: all, selectedPrompt: unlisted });
+ await act(async () => {
+ container
+ .querySelector(
+ 'button[aria-label="settings.general.promptActions.edit"]',
+ )!
+ .click();
+ });
+ expect(lastModalProps().onDuplicate).toBeUndefined();
+ });
+
it('keeps the row and shows an error when the delete fails', async () => {
deletePrompt.mockReturnValue(json({ success: false }, false, 403));
const setPrompts = vi.fn();
diff --git a/frontend/src/settings/Prompts.tsx b/frontend/src/settings/Prompts.tsx
index 22029c81..2f38377d 100644
--- a/frontend/src/settings/Prompts.tsx
+++ b/frontend/src/settings/Prompts.tsx
@@ -211,11 +211,7 @@ export default function Prompts({
setEditPromptName(prompt.name);
setEditPromptContent('');
handleFetchPromptContent(prompt.id);
- setCurrentPromptEdit({
- id: prompt.id,
- name: prompt.name,
- type: prompt.type,
- });
+ setCurrentPromptEdit(prompt);
setModalState('ACTIVE');
setOpen(false);
};
diff --git a/frontend/tsconfig.node.tsbuildinfo b/frontend/tsconfig.node.tsbuildinfo
new file mode 100644
index 00000000..4bd24cc2
--- /dev/null
+++ b/frontend/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2025.full.d.ts","./node_modules/vitest/optional-runtime-types.d.ts","./node_modules/tinybench/dist/index.d.ts","./node_modules/vitest/dist/chunks/config.d.cu_b-wjj.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/dist/node/chunks/modulerunnertransport.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/vitest/dist/chunks/rpc.d.da9utv4e.d.ts","./node_modules/vitest/dist/chunks/environment.d.c6xyahwa.d.ts","./node_modules/vitest/dist/chunks/worker.d.mlmnzoje.d.ts","./node_modules/vitest/dist/chunks/browser.d.g5thl309.d.ts","./node_modules/vitest/dist/chunks/task-utils.d.bzm4gsqd.d.ts","./node_modules/@vitest/mocker/dist/registry.d-xlx_foyf.d.ts","./node_modules/@vitest/mocker/dist/index.d-d4wtotqw.d.ts","./node_modules/@vitest/mocker/dist/index.d.ts","./node_modules/vitest/dist/chunks/evaluatedmodules.d.bxj5omdx.d.ts","./node_modules/expect-type/dist/utils.d.ts","./node_modules/expect-type/dist/overloads.d.ts","./node_modules/expect-type/dist/branding.d.ts","./node_modules/expect-type/dist/messages.d.ts","./node_modules/expect-type/dist/index.d.ts","./node_modules/@types/deep-eql/index.d.ts","./node_modules/assertion-error/index.d.ts","./node_modules/@types/chai/index.d.ts","./node_modules/vitest/dist/index.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/rolldown/dist/shared/logging-xuho4may.d.mts","./node_modules/@oxc-project/types/types.d.ts","./node_modules/rolldown/dist/shared/binding-dzunhvw4.d.mts","./node_modules/@rolldown/pluginutils/dist/filter/index.d.mts","./node_modules/@rolldown/pluginutils/dist/index.d.mts","./node_modules/rolldown/dist/shared/define-config-djhybh6s.d.mts","./node_modules/rolldown/dist/index.d.mts","./node_modules/rolldown/dist/parse-ast-index.d.mts","./node_modules/vite/types/internal/rolluptypecompat.d.ts","./node_modules/rolldown/dist/shared/constructors-bnig8fec.d.mts","./node_modules/rolldown/dist/plugins-index.d.mts","./node_modules/rolldown/dist/shared/transform-cvse9_mh.d.mts","./node_modules/rolldown/dist/utils-index.d.mts","./node_modules/vite/types/internal/devtoolsoptions.d.ts","./node_modules/vite/types/internal/esbuildoptions.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/rolldown/dist/experimental-index.d.mts","./node_modules/vite/types/internal/terseroptions.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/types/internal/csspreprocessoroptions.d.ts","./node_modules/vite/node_modules/lightningcss/node/ast.d.ts","./node_modules/vite/node_modules/lightningcss/node/targets.d.ts","./node_modules/vite/node_modules/lightningcss/node/index.d.ts","./node_modules/vite/types/internal/lightningcssoptions.d.ts","./node_modules/rolldown/dist/filter-index.d.mts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@vitejs/plugin-react/types/optionaltypes.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@rollup/pluginutils/types/index.d.ts","./node_modules/prettier/doc.d.ts","./node_modules/prettier/index.d.ts","./node_modules/@svgr/babel-plugin-transform-svg-component/dist/index.d.ts","./node_modules/@svgr/babel-preset/dist/index.d.ts","./node_modules/@svgr/core/dist/index.d.ts","./node_modules/vite-plugin-svgr/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[114,163,180,181],[114,163,180,181,217],[114,163,180,181,262],[114,163,180,181,266],[114,163,180,181,265,267],[105,106,114,163,180,181],[114,160,161,163,180,181],[114,162,163,180,181],[163,180,181],[114,163,168,180,181,198],[114,163,164,169,174,180,181,183,195,206],[114,163,164,165,174,180,181,183],[109,110,111,114,163,180,181],[114,163,166,180,181,207],[114,163,167,168,175,180,181,184],[114,163,168,180,181,195,203],[114,163,169,171,174,180,181,183],[114,162,163,170,180,181],[114,163,171,172,180,181],[114,163,173,174,180,181],[114,162,163,174,180,181],[114,163,174,175,176,180,181,195,206],[114,163,174,175,176,180,181,190,195,198],[114,155,163,171,174,177,180,181,183,195,206],[114,163,174,175,177,178,180,181,183,195,203,206],[114,163,177,179,180,181,195,203,206],[112,113,114,115,116,117,118,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212],[114,163,174,180,181],[114,163,180,181,182,206],[114,163,171,174,180,181,183,195],[114,163,180,181,184],[114,163,180,181,185],[114,162,163,180,181,186],[114,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212],[114,163,180,181,188],[114,163,180,181,189],[114,163,174,180,181,190,191],[114,163,180,181,190,192,207,209],[114,163,175,180,181],[114,163,174,180,181,195,196,198],[114,163,180,181,197,198],[114,163,180,181,195,196],[114,163,180,181,198],[114,163,180,181,199],[114,160,163,180,181,195,200,206],[114,163,174,180,181,201,202],[114,163,180,181,201,202],[114,163,168,180,181,183,195,203],[114,163,180,181,204],[114,163,180,181,183,205],[114,163,177,180,181,189,206],[114,163,168,180,181,207],[114,163,180,181,195,208],[114,163,180,181,182,209],[114,163,180,181,210],[114,155,163,180,181],[114,155,163,174,176,180,181,186,195,198,206,208,209,211],[114,163,180,181,195,212],[114,163,180,181,259,260],[96,114,163,180,181],[96,97,114,163,180,181],[100,101,114,163,180,181],[100,101,102,103,114,163,180,181],[100,102,114,163,180,181],[100,114,163,180,181],[114,163,180,181,247],[114,163,180,181,245,247],[114,163,180,181,236,244,245,246,248,250],[114,163,180,181,234],[114,163,180,181,237,242,247,250],[114,163,180,181,233,250],[114,163,180,181,237,238,241,242,243,250],[114,163,180,181,237,238,239,241,242,250],[114,163,180,181,234,235,236,237,238,242,243,244,246,247,248,250],[114,163,180,181,250],[114,163,180,181,232,234,235,236,237,238,239,241,242,243,244,245,246,247,248,249],[114,163,180,181,232,250],[114,163,180,181,237,239,240,242,243,250],[114,163,180,181,241,250],[114,163,180,181,242,243,247,250],[114,163,180,181,235,245],[114,163,180,181,264],[114,163,180,181,216,219,223,225],[114,163,180,181,219],[114,163,180,181,214,216,219],[114,163,180,181,215,216],[114,163,180,181,216,219,223],[114,163,180,181,215],[114,163,180,181,216,219],[114,163,180,181,214,215,216,218],[114,163,180,181,214,216],[114,163,180,181,215,216,225],[114,127,131,163,180,181,206],[114,127,163,180,181,195,206],[114,122,163,180,181],[114,124,127,163,180,181,203,206],[114,163,180,181,183,203],[114,163,180,181,213],[114,122,163,180,181,213],[114,124,127,163,180,181,183,206],[114,119,120,123,126,163,174,180,181,195,206],[114,127,134,163,180,181],[114,119,125,163,180,181],[114,127,148,149,163,180,181],[114,123,127,163,180,181,198,206,213],[114,148,163,180,181,213],[114,121,122,163,180,181,213],[114,127,163,180,181],[114,121,122,123,124,125,126,127,128,129,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,149,150,151,152,153,154,163,180,181],[114,127,142,163,180,181],[114,127,134,135,163,180,181],[114,125,127,135,136,163,180,181],[114,126,163,180,181],[114,119,122,127,163,180,181],[114,127,131,135,136,163,180,181],[114,131,163,180,181],[114,125,127,130,163,180,181,206],[114,119,124,127,134,163,180,181],[114,163,180,181,195],[114,122,127,148,163,180,181,211,213],[114,163,180,181,259,263,268],[86,114,163,180,181],[86,87,88,90,114,163,174,175,177,178,179,180,181,183,195,203,206,212,213,220,221,222,224,226,227,228,229,230,231,251,252,256,257,258,259],[86,87,88,89,114,163,180,181],[114,163,180,181,253,254],[88,114,163,180,181],[114,163,180,181,255],[114,163,180,181,220,229,259],[114,163,180,181,220,259],[85,91,93,114,163,180,181],[83,84,114,163,180,181],[85,114,163,180,181],[90,114,163,180,181],[85,90,114,163,180,181],[85,108,114,163,180,181],[85,90,91,92,114,163,180,181],[83,84,85,90,91,92,93,94,95,98,99,104,107,114,163,180,181],[108,114,163,180,181,185,259,261,269]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"1e9332c23e9a907175e0ffc6a49e236f97b48838cc8aec9ce7e4cec21e544b65","impliedFormat":1},{"version":"3753fbc1113dc511214802a2342280a8b284ab9094f6420e7aa171e868679f91","impliedFormat":1},{"version":"999ca32883495a866aa5737fe1babc764a469e4cde6ee6b136a4b9ae68853e4b","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"0bd714129fca875f7d4c477a1a392200b0bcd13fb2e80928cd334b63830ea047","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2c9037ae6cd2c52d80ceef0b3c5ffdb488627d71529cf4f63776daf11161c9a","affectsGlobalScope":true,"impliedFormat":1},{"version":"135d5cf4d345f59f1a9caadfafcd858d3d9cc68290db616cc85797224448cccc","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc238c3f81c2984751932b6aab223cd5b830e0ac6cad76389e5e9d2ffc03287d","affectsGlobalScope":true,"impliedFormat":1},{"version":"4a07f9b76d361f572620927e5735b77d6d2101c23cdd94383eb5b706e7b36357","affectsGlobalScope":true,"impliedFormat":1},{"version":"7c4e8dc6ab834cc6baa0227e030606d29e3e8449a9f67cdf5605ea5493c4db29","affectsGlobalScope":true,"impliedFormat":1},{"version":"de7ba0fd02e06cd9a5bd4ab441ed0e122735786e67dde1e849cced1cd8b46b78","affectsGlobalScope":true,"impliedFormat":1},{"version":"6148e4e88d720a06855071c3db02069434142a8332cf9c182cda551adedf3156","affectsGlobalScope":true,"impliedFormat":1},{"version":"d63dba625b108316a40c95a4425f8d4294e0deeccfd6c7e59d819efa19e23409","affectsGlobalScope":true,"impliedFormat":1},{"version":"0568d6befee03dd435bed4fc25c4e46865b24bdcb8c563fdc21f580a2c301904","affectsGlobalScope":true,"impliedFormat":1},{"version":"30d62269b05b584741f19a5369852d5d34895aa2ac4fd948956f886d15f9cc0d","affectsGlobalScope":true,"impliedFormat":1},{"version":"f128dae7c44d8f35ee42e0a437000a57c9f06cc04f8b4fb42eebf44954d53dc8","affectsGlobalScope":true,"impliedFormat":1},{"version":"ffbe6d7b295306b2ba88030f65b74c107d8d99bdcf596ea99c62a02f606108b0","affectsGlobalScope":true,"impliedFormat":1},{"version":"996fb27b15277369c68a4ba46ed138b4e9e839a02fb4ec756f7997629242fd9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"79b712591b270d4778c89706ca2cfc56ddb8c3f895840e477388f1710dc5eda9","affectsGlobalScope":true,"impliedFormat":1},{"version":"20884846cef428b992b9bd032e70a4ef88e349263f63aeddf04dda837a7dba26","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"e03da518b01b46a4c99a1f88cd727ee98ddf14492c43dae1ae7a63e992971bab","impliedFormat":1},{"version":"c76c02846ba7d40b9b3488f0e8d75d02cbdee2f0bc5fcd55dd3bd2e1457646ea","impliedFormat":99},{"version":"883ab61941ae697bd5724f02abc0a4044dc5427e7fb002c95867204918c1a68c","impliedFormat":99},{"version":"2493d114a1f01f02d69b4b44265405dfd5bbf135e0f467838808c4137c71ce54","impliedFormat":99},{"version":"24c010698f5f7a0f1c216b963994d09ecfef77d7d5b4116b248068287ddb5964","impliedFormat":99},{"version":"c911b3bd083dade8fdb7775261aa25feae695d562a3b11209650b47be90043fc","impliedFormat":99},{"version":"5ef1bf71c773ca3df07f0c8c126af004a8e7310a7373ba4f049e7d3fb1b21921","impliedFormat":99},{"version":"4e003c868b0d8f8ad200b96cbc653e18e513fa23e1c19c4fe3cc25d4394efc47","impliedFormat":99},{"version":"93aed5bd196d1a1417d07e5c5b66a96107f0ee7d5c9027c2af12d876ab5e0f5e","impliedFormat":99},{"version":"fc620fc5a05d1d8499e790626f83b401604634dfd9c4b11042099a63883df8fb","impliedFormat":99},{"version":"123b46e873f6ae5902094478c2c58b725b3d92e1d36b53daf85b7732d032dfa8","impliedFormat":99},{"version":"11f54bcce4d6e4271122c6b06329b37f6d297141ad6e918b5181e17572158a10","impliedFormat":99},{"version":"dd8cb59c27a49fdc4b86eb755ddaaab5de0f30a627767f506fb2607ca4b2ec22","impliedFormat":99},{"version":"9500d6fd10c59daeab4a18b1d9aeaf13d790bd4a92a5f864d5f323dba395d4cc","impliedFormat":99},{"version":"8d4ea2431523a42682a9f2efb2ae662aa42a39983c9d40a19257b2445f50450e","impliedFormat":99},{"version":"29738162d88967e51557276cba80552cb8d2a4c823873b7f1531244860f0d178","impliedFormat":99},{"version":"fc07122b30042636bbf689305c75a764d6e3c2eeb75cd1033c96a6a7dc9e6e3f","impliedFormat":99},{"version":"b413fbc6658fe2774f8bf9a15cf4c53e586fc38a2d5256b3b9647da242c14389","impliedFormat":99},{"version":"dd51e53752b310bd20c9b1a87bbf12b1fe2be7fe40f505b43199496481096275","impliedFormat":1},{"version":"a87be4662442b3feeffc331ecafe6b36cafd08727e2d7f2425a5099577e7fd18","impliedFormat":1},{"version":"cd4cd9220a1ba793bc935e76d8e5481c110a90d9868ae7866a182ee71cdb6abb","impliedFormat":1},{"version":"0a7fb8619b10bc05fd933ca9ac1c8b2ab2220be7a57b57565c3ac158595494ef","impliedFormat":1},{"version":"c4a5f91feb9c5a6b2a91089d959c38391b79a961db3b9cc73b8877d57ad7dcdc","impliedFormat":1},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"3589cb90a45acfb8c2336b34a9e340f036a6aecfdb71742047d37748dfa52816","impliedFormat":99},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"78dbea00e90d2df8ea3dbef0cc379d95b8be9b71cd6bde4c28728f306811803b","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e1e46d0a9837ee058c100501080c920fa98081ea3956af0374308ba6f22a33e","impliedFormat":1},{"version":"272ca407e0c9068bdc5152552d876e68037ceae3de62e529306403e973dec8e1","impliedFormat":1},{"version":"fa7834c715d5357e4540cee40ce96c3250ddb67a7b879a6b7fa0e86d6696f121","impliedFormat":1},{"version":"22dfb07a7ab15b66ac043829056fe70124844636ae719551812ac631ba04985b","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"0cb167c371eaa8c869f8a7656a7296f2e4fae43b4d8b803a680236b24794e5f9","impliedFormat":1},{"version":"0a839dba0287cc0481ad4beedd48a1c64acf1e212ae865d1315f7007ca215161","impliedFormat":1},{"version":"38dc4655376cd1a4bd6bb3763d92949233e33d38d3dd3cbea7bbf218175a38ef","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"208a6a0bfb227bdf8fb964799d0a206c75cf03ccd72e63bf5495c9331354c6d6","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"48a679952eefe4cb776d5a0e1ccba2d3eb53b57448bbb7abc1fcebcbd5440188","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2d14da6ecb49bf828d83948765ec2d3a579d476bbb9645e749610baa6ec880ca","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"0aef708fb4c7a6b915e8305cbfac40cd207b032dbaabe9a01889a5fff3254681","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"ad9bdafb4e7abf14cc53ce7970486a84c87831e62891e5dfe798ddcd55e84701","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"71d3ae6a5e73ca4130762560425e00984ebaff64d5353a3333d1bb7eb86ef336","impliedFormat":1},{"version":"fc5ad2ec8b130a59612c5cfcc2cdca9bcdd80851adb8acdb004aaeff00cc978d","impliedFormat":99},{"version":"f76d2226b20ec0d3e884114cfbbb933cb4d25d26cc306e8315e21a0e3afafc39","impliedFormat":99},{"version":"709b4ace1214759ca558d2b85c1ac20011c384077769ed8e08d6b2e6d68ef6d9","impliedFormat":99},{"version":"638a22f8e16b31c37297483fa38cfc760ed309b16d4c606a7b4532f4394c9c0c","impliedFormat":99},{"version":"9394183f4c37b8591156f32e41223c9e0dd211eefe7499155d4cdf15268eaea8","impliedFormat":99},{"version":"2403907e4ef7f180350b25e0acb6ffa78c610047d159ecc6964b4229bfb40fb2","impliedFormat":99},{"version":"1504305fd16da58cd87b9ed49129a10b7fa354f414f539bdfbe403658cd6be8f","impliedFormat":99},{"version":"0824e917b53180b0b433f4fa85a1cc1d689a6c5f118bfdbe2086aa3b328c12db","impliedFormat":99},{"version":"7d3e062a778b8f5ea4f0cac7e925e31f88e6739812ebc5f827474324a4048f14","impliedFormat":99},{"version":"6fd401f9fc270efbde915a01325950b046bbfa4e041c4cde91566b3d178b05fc","impliedFormat":99},{"version":"9327db71e743ca5810396f33be57f88bae2b52d53c00aeb2668f41a6ef14c52a","impliedFormat":99},{"version":"e27361b231a041b6c4da9fae61463d39d99f64926e1cd732513fd413b77bf869","impliedFormat":99},{"version":"07cb5a2736a656d1d53b10f7717d5e816613741510a42dfc32f0f257406900e9","impliedFormat":99},{"version":"1f97a93af87366e65131932b6f5173b8eebfb7c3a61aa32626cc348b51c77b80","impliedFormat":99},{"version":"e0864480ea083087d705f9405bd6bf59b795e8474c3447f0d6413b2bce535a09","impliedFormat":99},{"version":"e67cbea16f1994af89efd700542dbf3828a46a52b29e4d67e801bd7869dc103c","impliedFormat":99},{"version":"d127908bdafc0f2d80a2ad9b17b6599d411545ced7773da2374e75d7992ccef2","impliedFormat":99},{"version":"f582b0fcbf1eea9b318ab92fb89ea9ab2ebb84f9b60af89328a91155e1afce72","impliedFormat":99},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"38cb107048cd8ba54a70014ef9a30cf57bee0d9f10a0ca4cefa974056e1ee460","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"9a195d8476f48523446ece812e5450b5b1ec8c3b6abf99efb8ee2479524f6f56","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"d8a62a1763683460cfc7c6d66214859909d3f91f34b6e9009aa9443b241f08c0","affectsGlobalScope":true,"impliedFormat":99},{"version":"f3fe8197217cb6fd6e07c0eaa0966e4ece77cd779cbfc55767a93704cc692e04","impliedFormat":1},{"version":"a18642ddf216f162052a16cba0944892c4c4c977d3306a87cb673d46abbb0cbf","impliedFormat":1},{"version":"617112682fcb55805f0a0e16450397edc016881962eee4298f4f4e3ef795aee8","impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":99},{"version":"71fd5e58251bf9faae3c6c3413db7fe917abce3cbfb5390daac2d971dca8f68d","impliedFormat":99},{"version":"e50d04d261120f8f9d07e85698735d5b88f3a66bc395c08ce26c3d817d141a73","affectsGlobalScope":true,"impliedFormat":99},{"version":"8ecc9dd98b423db66eefccce5e7fcf5d6fb452d5673fcdbe751cf7977dd93b9f","impliedFormat":99},{"version":"6b50db6951aa5dd210715a37636f6f549ffc21556933ed6ff8a1653305a4c360","impliedFormat":99},{"version":"087049bb778b44ad4e720892e4bb22558e54bd6b51d5d746bd0e688eda35f4d8","impliedFormat":99},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"58eb22ce690f7941a952f307d51a210195798c9fbc381470fe7e0adb228d4c92","impliedFormat":1},{"version":"f63cb353cd53da6be4a34f6fdece6316dac14fd62cccf9a4d2ce6bab2c37bc8c","impliedFormat":1},{"version":"cace2314ce18e7efedfcdaddb80e6428bb3d1b52a874eaa6bff779ba0c36de2b","impliedFormat":1},{"version":"3d922ac35e7bd201c09c71d0a3be9cab0ac41bdd0d5115f2734c8555629e5414","impliedFormat":1},{"version":"ae18a824baa6829b4b687f4e678d97c2b3f0ee75a82e2cff792180002f1e2a82","impliedFormat":1},{"version":"fe1baccba85e2af0fdaca57b32b34f3fd602609bb0b29aeb0609000dbcd75446","impliedFormat":1},{"version":"b7c5ef46a22a80ddac8f1e794c9b91c7082b515936e7dadb2c537ceb66b28ec5","impliedFormat":99},{"version":"1c7b696b935809569a770386fe99319147ed3d4d67ab9bfa1f35b632380b3bfb","signature":"f1a1b21a223c18a29308ebff0b002317e4bb8aa5e350164f8c8c3b8bde33a535"}],"root":[270],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99},"referencedMap":[[215,1],[217,1],[218,2],[263,3],[266,1],[267,4],[268,5],[107,6],[105,1],[262,1],[160,7],[161,7],[162,8],[114,9],[163,10],[164,11],[165,12],[109,1],[112,13],[110,1],[111,1],[166,14],[167,15],[168,16],[169,17],[170,18],[171,19],[172,19],[173,20],[174,21],[175,22],[176,23],[115,1],[113,1],[177,24],[178,25],[179,26],[213,27],[180,28],[181,1],[182,29],[183,30],[184,31],[185,32],[186,33],[187,34],[188,35],[189,36],[190,37],[191,37],[192,38],[193,1],[194,39],[195,40],[197,41],[196,42],[198,43],[199,44],[200,45],[201,46],[202,47],[203,48],[204,49],[205,50],[206,51],[207,52],[208,53],[209,54],[210,55],[116,1],[117,1],[118,1],[156,56],[157,1],[158,1],[159,43],[211,57],[212,58],[261,59],[260,1],[97,60],[98,61],[96,1],[106,1],[102,62],[104,63],[103,64],[101,65],[100,1],[248,66],[246,67],[247,68],[235,69],[236,67],[243,70],[234,71],[239,72],[249,1],[240,73],[245,74],[251,75],[250,76],[233,77],[241,78],[242,79],[237,80],[244,66],[238,81],[264,1],[265,82],[230,83],[257,84],[220,85],[221,86],[224,87],[216,88],[223,89],[219,90],[214,1],[225,91],[226,92],[232,1],[84,1],[80,1],[81,1],[15,1],[13,1],[14,1],[19,1],[18,1],[2,1],[20,1],[21,1],[22,1],[23,1],[24,1],[25,1],[26,1],[27,1],[3,1],[28,1],[29,1],[4,1],[30,1],[34,1],[31,1],[32,1],[33,1],[35,1],[36,1],[37,1],[5,1],[38,1],[39,1],[40,1],[41,1],[6,1],[45,1],[42,1],[43,1],[44,1],[46,1],[7,1],[47,1],[52,1],[53,1],[48,1],[49,1],[50,1],[51,1],[8,1],[57,1],[54,1],[55,1],[56,1],[58,1],[9,1],[59,1],[60,1],[61,1],[63,1],[62,1],[64,1],[65,1],[10,1],[66,1],[67,1],[68,1],[11,1],[69,1],[70,1],[71,1],[72,1],[73,1],[74,1],[12,1],[75,1],[82,1],[76,1],[77,1],[78,1],[79,1],[1,1],[17,1],[16,1],[134,93],[144,94],[133,93],[154,95],[125,96],[124,97],[153,98],[147,99],[152,100],[127,101],[141,102],[126,103],[150,104],[122,105],[121,98],[151,106],[123,107],[128,108],[129,1],[132,108],[119,1],[155,109],[145,110],[136,111],[137,112],[139,113],[135,114],[138,115],[148,98],[130,116],[131,117],[140,118],[120,119],[143,110],[142,108],[146,1],[149,120],[269,121],[87,122],[259,123],[90,124],[253,1],[255,125],[254,1],[88,122],[86,1],[89,126],[258,1],[252,1],[227,1],[228,1],[256,127],[222,128],[231,1],[229,129],[94,130],[85,131],[92,132],[99,133],[91,134],[95,135],[93,136],[108,137],[83,1],[270,138]],"semanticDiagnosticsPerFile":[[266,[{"start":43,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}},{"start":90,"length":17,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/template'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/template/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/template","mode":99}}]}}]],[267,[{"start":26,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}}]],[268,[{"start":71,"length":6,"messageText":"Cannot find module 'svgo' or its corresponding type declarations.","category":1,"code":2307},{"start":171,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}}]]],"latestChangedDtsFile":"./vite.config.d.ts","version":"6.0.3"}
\ No newline at end of file
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
new file mode 100644
index 00000000..bf201af2
--- /dev/null
+++ b/frontend/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/hero.tsx","./src/navigation.tsx","./src/pagenotfound.tsx","./src/env.ts","./src/main.tsx","./src/store.ts","./src/vite-env.d.ts","./src/admin/activity.tsx","./src/admin/adminui.test.ts","./src/admin/adminui.tsx","./src/admin/admins.tsx","./src/admin/loaderror.test.tsx","./src/admin/overview.tsx","./src/admin/quotaeditor.tsx","./src/admin/quotas.tsx","./src/admin/usage.tsx","./src/admin/usagechart.test.ts","./src/admin/usagechart.tsx","./src/admin/userquotamodal.tsx","./src/admin/userusagemodal.tsx","./src/admin/users.tsx","./src/admin/index.tsx","./src/admin/quotautils.test.ts","./src/admin/quotautils.ts","./src/admin/usagechartdata.test.ts","./src/admin/usagechartdata.ts","./src/agents/agentcard.test.tsx","./src/agents/agentcard.tsx","./src/agents/agentlogs.test.tsx","./src/agents/agentlogs.tsx","./src/agents/agentpageheader.test.tsx","./src/agents/agentpageheader.tsx","./src/agents/agentpreview.tsx","./src/agents/agentrouteguard.test.tsx","./src/agents/agentrouteguard.tsx","./src/agents/agentslist.tsx","./src/agents/foldercard.tsx","./src/agents/newagent.test.tsx","./src/agents/newagent.tsx","./src/agents/sharedagent.tsx","./src/agents/sharedagentcard.test.tsx","./src/agents/sharedagentcard.tsx","./src/agents/sharedagentgate.tsx","./src/agents/agentaccess.test.ts","./src/agents/agentaccess.ts","./src/agents/agentpreviewslice.ts","./src/agents/agents.config.ts","./src/agents/index.tsx","./src/agents/paths.test.ts","./src/agents/paths.ts","./src/agents/components/agentpagetoolbar.test.tsx","./src/agents/components/agentpagetoolbar.tsx","./src/agents/components/agentpreviewsheet.test.tsx","./src/agents/components/agentpreviewsheet.tsx","./src/agents/components/agenttypemodal.test.tsx","./src/agents/components/agenttypemodal.tsx","./src/agents/components/guardrailevents.test.tsx","./src/agents/components/guardrailevents.tsx","./src/agents/components/guardrailssection.test.tsx","./src/agents/components/guardrailssection.tsx","./src/agents/components/sponsoredresourcesnotice.test.tsx","./src/agents/components/sponsoredresourcesnotice.tsx","./src/agents/hooks/useagentsearch.ts","./src/agents/hooks/useagentsfetch.ts","./src/agents/schedules/rundetaildrawer.test.tsx","./src/agents/schedules/rundetaildrawer.tsx","./src/agents/schedules/runlog.test.tsx","./src/agents/schedules/runlog.tsx","./src/agents/schedules/scheduleformmodal.tsx","./src/agents/schedules/schedulerow.test.tsx","./src/agents/schedules/schedulerow.tsx","./src/agents/schedules/schedulertoolcallcard.test.ts","./src/agents/schedules/schedulertoolcallcard.tsx","./src/agents/schedules/schedulesview.test.tsx","./src/agents/schedules/schedulesview.tsx","./src/agents/schedules/statusbadge.test.tsx","./src/agents/schedules/statusbadge.tsx","./src/agents/schedules/timezonecombobox.test.ts","./src/agents/schedules/timezonecombobox.tsx","./src/agents/schedules/cronbuilder.test.ts","./src/agents/schedules/cronbuilder.ts","./src/agents/schedules/schedulesslice.test.ts","./src/agents/schedules/schedulesslice.ts","./src/agents/types/index.ts","./src/agents/types/schedule.ts","./src/agents/types/workflow.ts","./src/agents/workflow/canvascontrols.tsx","./src/agents/workflow/nodepalette.test.tsx","./src/agents/workflow/nodepalette.tsx","./src/agents/workflow/workflowbuilder.tsx","./src/agents/workflow/workflowminimap.test.tsx","./src/agents/workflow/workflowmodelscontext.ts","./src/agents/workflow/workflowpreview.test.tsx","./src/agents/workflow/workflowpreview.tsx","./src/agents/workflow/workflowrunartifacts.test.tsx","./src/agents/workflow/workflowrunartifacts.tsx","./src/agents/workflow/codenodeconfig.test.ts","./src/agents/workflow/codenodeconfig.ts","./src/agents/workflow/documentconfig.test.ts","./src/agents/workflow/documentconfig.ts","./src/agents/workflow/nodetones.test.ts","./src/agents/workflow/nodetones.ts","./src/agents/workflow/simplecel.test.ts","./src/agents/workflow/simplecel.ts","./src/agents/workflow/workflowhelpers.test.ts","./src/agents/workflow/workflowhelpers.ts","./src/agents/workflow/workflowpreviewslice.test.ts","./src/agents/workflow/workflowpreviewslice.ts","./src/agents/workflow/components/mobileblocker.tsx","./src/agents/workflow/components/nodedocumentscontrol.tsx","./src/agents/workflow/components/prompttextarea.test.tsx","./src/agents/workflow/components/prompttextarea.tsx","./src/agents/workflow/components/workflowdetailssheet.test.tsx","./src/agents/workflow/components/workflowdetailssheet.tsx","./src/agents/workflow/hooks/useundoredo.ts","./src/agents/workflow/nodes/basenode.tsx","./src/agents/workflow/nodes/codenode.tsx","./src/agents/workflow/nodes/conditionnode.tsx","./src/agents/workflow/nodes/outputvariableline.tsx","./src/agents/workflow/nodes/setstatenode.tsx","./src/agents/workflow/nodes/index.tsx","./src/agents/workflow/panels/agentpanel.test.tsx","./src/agents/workflow/panels/agentpanel.tsx","./src/agents/workflow/panels/codepanel.tsx","./src/agents/workflow/panels/conditionpanel.test.tsx","./src/agents/workflow/panels/conditionpanel.tsx","./src/agents/workflow/panels/nodepanel.test.tsx","./src/agents/workflow/panels/nodepanel.tsx","./src/agents/workflow/panels/notepanel.tsx","./src/agents/workflow/panels/statepanel.test.tsx","./src/agents/workflow/panels/statepanel.tsx","./src/agents/workflow/panels/types.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/throttle.ts","./src/api/services/adminservice.ts","./src/api/services/conversationservice.ts","./src/api/services/custommodelsservice.ts","./src/api/services/devicesservice.ts","./src/api/services/modelservice.ts","./src/api/services/patservice.test.ts","./src/api/services/patservice.ts","./src/api/services/schedulesservice.test.ts","./src/api/services/schedulesservice.ts","./src/api/services/teamsservice.test.ts","./src/api/services/teamsservice.ts","./src/api/services/userservice.ts","./src/components/actionbuttons.tsx","./src/components/adminroute.test.tsx","./src/components/adminroute.tsx","./src/components/artifactsidebar.test.tsx","./src/components/artifactsidebar.tsx","./src/components/chunks.test.tsx","./src/components/chunks.tsx","./src/components/configfields.test.tsx","./src/components/configfields.tsx","./src/components/connectorauth.test.tsx","./src/components/connectorauth.tsx","./src/components/connectortree.test.tsx","./src/components/connectortree.tsx","./src/components/copybutton.test.tsx","./src/components/copybutton.tsx","./src/components/documentartifactview.tsx","./src/components/errorboundary.test.tsx","./src/components/errorboundary.tsx","./src/components/filepicker.test.tsx","./src/components/filepicker.tsx","./src/components/filetree.tsx","./src/components/fileupload.test.tsx","./src/components/fileupload.tsx","./src/components/googledrivepicker.tsx","./src/components/graphview.tsx","./src/components/head.tsx","./src/components/help.tsx","./src/components/markdownpreview.tsx","./src/components/mermaidrenderer.test.tsx","./src/components/mermaidrenderer.tsx","./src/components/messageinput.test.tsx","./src/components/messageinput.tsx","./src/components/multiselectpopover.test.tsx","./src/components/multiselectpopover.tsx","./src/components/notification.tsx","./src/components/pagetoolbar.test.tsx","./src/components/pagetoolbar.tsx","./src/components/profilebutton.tsx","./src/components/rolebadge.test.tsx","./src/components/rolebadge.tsx","./src/components/searchinput.test.tsx","./src/components/searchinput.tsx","./src/components/skeletonloader.test.tsx","./src/components/skeletonloader.tsx","./src/components/sourcemarkdown.test.tsx","./src/components/sourcemarkdown.tsx","./src/components/sourcespopoverfooter.test.tsx","./src/components/sourcespopoverfooter.tsx","./src/components/statcard.test.tsx","./src/components/statcard.tsx","./src/components/texttospeechbutton.tsx","./src/components/toolicon.tsx","./src/components/uploadtoast.test.tsx","./src/components/uploadtoast.tsx","./src/components/viewonlynotice.test.tsx","./src/components/viewonlynotice.tsx","./src/components/wikiviewer.test.tsx","./src/components/wikiviewer.tsx","./src/components/artifactviewutils.test.ts","./src/components/artifactviewutils.ts","./src/components/chunkutils.test.ts","./src/components/chunkutils.ts","./src/components/graphviewutils.test.ts","./src/components/graphviewutils.ts","./src/components/mermaidrenderqueue.test.ts","./src/components/mermaidsecurity.test.ts","./src/components/mermaidsecurity.ts","./src/components/useartifactbytes.ts","./src/components/wikiviewerutils.test.ts","./src/components/wikiviewerutils.ts","./src/components/graph/graphcanvascontrols.tsx","./src/components/graph/graphchunksheet.test.tsx","./src/components/graph/graphchunksheet.tsx","./src/components/graph/graphentities.tsx","./src/components/graph/graphentitysearch.tsx","./src/components/graph/graphnodepanel.test.tsx","./src/components/graph/graphnodepanel.tsx","./src/components/graph/graphsourceview.test.tsx","./src/components/graph/graphsourceview.tsx","./src/components/graph/graphtypedot.tsx","./src/components/graph/graphcanvasutils.test.ts","./src/components/graph/graphcanvasutils.ts","./src/components/graph/usegraphnodedetail.ts","./src/components/message-input/attachfilebutton.tsx","./src/components/message-input/attachmentchiplist.test.tsx","./src/components/message-input/attachmentchiplist.tsx","./src/components/message-input/micbutton.tsx","./src/components/message-input/sourcestrigger.tsx","./src/components/message-input/toolstrigger.tsx","./src/components/message-input/armedsend.test.tsx","./src/components/message-input/armedsend.ts","./src/components/message-input/attachmentreadability.test.ts","./src/components/message-input/attachmentreadability.ts","./src/components/message-input/composercontrols.test.tsx","./src/components/message-input/index.ts","./src/components/message-input/uploadstallguard.test.ts","./src/components/message-input/uploadstallguard.ts","./src/components/tree/pathheader.test.tsx","./src/components/tree/pathheader.tsx","./src/components/tree/readerpanel.tsx","./src/components/tree/sourceeditsheet.test.tsx","./src/components/tree/sourceeditsheet.tsx","./src/components/tree/sourcenavigator.test.tsx","./src/components/tree/sourcenavigator.tsx","./src/components/tree/treebrowser.test.tsx","./src/components/tree/treebrowser.tsx","./src/components/tree/navigatorutils.test.ts","./src/components/tree/navigatorutils.ts","./src/components/tree/types.ts","./src/components/tree/usereingestwait.ts","./src/components/types/index.ts","./src/components/ui/accordion.test.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert.test.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.test.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.test.tsx","./src/components/ui/badge.tsx","./src/components/ui/bar-tint-reset.ts","./src/components/ui/breadcrumb.test.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.test.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.test.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.test.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.test.tsx","./src/components/ui/command.tsx","./src/components/ui/description-list.test.tsx","./src/components/ui/description-list.tsx","./src/components/ui/dialog.test.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.test.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/dropzone.test.tsx","./src/components/ui/dropzone.tsx","./src/components/ui/empty-state.test.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/form-field.test.tsx","./src/components/ui/form-field.tsx","./src/components/ui/icon-button.test.tsx","./src/components/ui/icon-button.tsx","./src/components/ui/input.test.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/list-row.test.tsx","./src/components/ui/list-row.tsx","./src/components/ui/loading-state.test.tsx","./src/components/ui/loading-state.tsx","./src/components/ui/message-scroller.tsx","./src/components/ui/modal.test.tsx","./src/components/ui/modal.tsx","./src/components/ui/multi-select.test.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/option-card.test.tsx","./src/components/ui/option-card.tsx","./src/components/ui/overlay-chrome.test.tsx","./src/components/ui/pagination-size.test.tsx","./src/components/ui/pagination.test.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.test.tsx","./src/components/ui/progress.tsx","./src/components/ui/section-header.test.tsx","./src/components/ui/section-header.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.test.tsx","./src/components/ui/separator.tsx","./src/components/ui/setting-row.test.tsx","./src/components/ui/setting-row.tsx","./src/components/ui/sheet.test.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.test.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/spinner.test.tsx","./src/components/ui/spinner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.test.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.test.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.test.tsx","./src/components/ui/textarea.tsx","./src/components/ui/time-picker.test.tsx","./src/components/ui/time-picker.tsx","./src/components/ui/toast.test.tsx","./src/components/ui/toast.tsx","./src/components/ui/toggle-group.test.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/tooltip.test.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-focus-return.ts","./src/constants/fileupload.test.ts","./src/constants/fileupload.ts","./src/conversation/answerflow.test.tsx","./src/conversation/answerflow.tsx","./src/conversation/conversation.tsx","./src/conversation/conversationbubble.test.tsx","./src/conversation/conversationbubble.tsx","./src/conversation/conversationmessages.test.tsx","./src/conversation/conversationmessages.tsx","./src/conversation/conversationtile.test.tsx","./src/conversation/conversationtile.tsx","./src/conversation/markdownanswer.codespans.test.tsx","./src/conversation/markdownanswer.math.test.tsx","./src/conversation/markdownanswer.sandbox.test.tsx","./src/conversation/markdownanswer.tsx","./src/conversation/researchprogress.tsx","./src/conversation/sharedconversation.tsx","./src/conversation/streamingstatusline.tsx","./src/conversation/wikiwritetoolcallcard.test.tsx","./src/conversation/answerflowintegration.test.tsx","./src/conversation/answersegments.test.ts","./src/conversation/answersegments.ts","./src/conversation/artifactchips.test.ts","./src/conversation/artifactchips.ts","./src/conversation/conversationhandlers.ts","./src/conversation/conversationlistener.test.ts","./src/conversation/conversationmodels.ts","./src/conversation/conversationslice.test.ts","./src/conversation/conversationslice.ts","./src/conversation/quotaerror.test.ts","./src/conversation/quotaerror.ts","./src/conversation/sandboxlinks.live.test.ts","./src/conversation/sandboxlinks.test.ts","./src/conversation/sandboxlinks.ts","./src/conversation/sharedconversationslice.ts","./src/conversation/wikitoolcall.test.ts","./src/conversation/wikitoolcall.ts","./src/conversation/markdown/answermarkdown.ts","./src/conversation/markdown/micromarkextension.ts","./src/conversation/markdown/remarkcitations.ts","./src/conversation/markdown/remarkdisplaymath.ts","./src/conversation/markdown/singledollarmath.ts","./src/conversation/markdown/texmath.ts","./src/conversation/types/index.ts","./src/design/designsystem.tsx","./src/design/cardsurfaces.lint.test.ts","./src/design/designrules.lint.test.ts","./src/events/eventstreamprovider.tsx","./src/events/dispatchevent.test.ts","./src/events/dispatchevent.ts","./src/events/eventstreamclient.ts","./src/events/useeventstream.ts","./src/hooks/fetchmeroles.test.ts","./src/hooks/index.ts","./src/hooks/usedarktheme.test.tsx","./src/hooks/usedatainitializer.ts","./src/hooks/usemediaquery.test.tsx","./src/hooks/usetokenauth.ts","./src/lib/markdown.test.tsx","./src/lib/markdown.tsx","./src/lib/utils.test.ts","./src/lib/utils.ts","./src/locale/accesstokens.test.ts","./src/locale/i18n.ts","./src/locale/logstrace.test.ts","./src/modals/accesstokencreatedmodal.tsx","./src/modals/addactionmodal.tsx","./src/modals/addtoolmodal.tsx","./src/modals/agentdetailsmodal.test.tsx","./src/modals/agentdetailsmodal.tsx","./src/modals/configtoolmodal.tsx","./src/modals/confirmationmodal.tsx","./src/modals/createaccesstokenmodal.tsx","./src/modals/custommodelmodal.test.tsx","./src/modals/custommodelmodal.tsx","./src/modals/foldermanagementmodal.tsx","./src/modals/importagentmodal.test.tsx","./src/modals/importagentmodal.tsx","./src/modals/importspecmodal.tsx","./src/modals/jwtmodal.tsx","./src/modals/mcpservermodal.test.tsx","./src/modals/mcpservermodal.tsx","./src/modals/movetofoldermodal.test.tsx","./src/modals/movetofoldermodal.tsx","./src/modals/regenerateaccesstokenmodal.tsx","./src/modals/searchconversationsmodal.test.tsx","./src/modals/searchconversationsmodal.tsx","./src/modals/shareconversationmodal.tsx","./src/modals/types/index.ts","./src/models/misc.ts","./src/models/types.ts","./src/navigation/detailbreadcrumb.tsx","./src/navigation/mobiletopbar.test.tsx","./src/navigation/mobiletopbar.tsx","./src/navigation/sectionindexpage.tsx","./src/navigation/sectionnav.tsx","./src/navigation/sectionpageheader.tsx","./src/navigation/sectionpills.tsx","./src/navigation/sectionrail.tsx","./src/navigation/sectionshell.test.tsx","./src/navigation/sectionshell.tsx","./src/navigation/sidebarlevel.tsx","./src/navigation/sidebarlevelprovider.tsx","./src/navigation/sidebarnavrow.test.tsx","./src/navigation/sections.test.ts","./src/navigation/sections.ts","./src/navigation/uselastapppath.ts","./src/navigation/usesectioncontext.ts","./src/navigation/usesectionresolver.ts","./src/notifications/actiontoast.test.tsx","./src/notifications/actiontoast.tsx","./src/notifications/teamnotificationtoast.test.tsx","./src/notifications/teamnotificationtoast.tsx","./src/notifications/toolapprovaltoast.test.tsx","./src/notifications/toolapprovaltoast.tsx","./src/notifications/actiontoastslice.test.ts","./src/notifications/actiontoastslice.ts","./src/notifications/dismissedpersistence.test.ts","./src/notifications/dismissedpersistence.ts","./src/notifications/notificationsslice.test.ts","./src/notifications/notificationsslice.ts","./src/preferences/promptsmodal.test.tsx","./src/preferences/promptsmodal.tsx","./src/preferences/preferenceapi.ts","./src/preferences/preferenceslice.test.ts","./src/preferences/preferenceslice.ts","./src/preferences/types/index.ts","./src/settings/analytics.tsx","./src/settings/converttowikimodal.tsx","./src/settings/custommodels.tsx","./src/settings/enablegraphragmodal.tsx","./src/settings/general.tsx","./src/settings/logs.tsx","./src/settings/pairdevicemodal.tsx","./src/settings/personalaccesstokens.tsx","./src/settings/prompts.test.tsx","./src/settings/prompts.tsx","./src/settings/remotedeviceconfig.test.tsx","./src/settings/remotedeviceconfig.tsx","./src/settings/sourceconfigmodal.test.tsx","./src/settings/sourceconfigmodal.tsx","./src/settings/sources.test.tsx","./src/settings/sources.tsx","./src/settings/teams.test.tsx","./src/settings/teams.tsx","./src/settings/testretrievalmodal.notices.test.tsx","./src/settings/testretrievalmodal.test.tsx","./src/settings/testretrievalmodal.tsx","./src/settings/toolconfig.test.tsx","./src/settings/toolconfig.tsx","./src/settings/tools.test.tsx","./src/settings/tools.tsx","./src/settings/accesstokenutils.test.ts","./src/settings/accesstokenutils.ts","./src/settings/foldseries.test.ts","./src/settings/foldseries.ts","./src/settings/graphbuildslice.test.ts","./src/settings/graphbuildslice.ts","./src/settings/graphragenableutils.test.ts","./src/settings/graphragenableutils.ts","./src/settings/index.test.tsx","./src/settings/index.tsx","./src/settings/wikiconvertutils.test.ts","./src/settings/wikiconvertutils.ts","./src/settings/components/retrievaloptions.test.tsx","./src/settings/components/retrievaloptions.tsx","./src/settings/components/usagequota.tsx","./src/settings/traces/tracechips.tsx","./src/settings/traces/tracesheet.test.tsx","./src/settings/traces/tracesheet.tsx","./src/settings/traces/tracespandetails.tsx","./src/settings/traces/tracewaterfall.tsx","./src/settings/traces/traceutils.test.ts","./src/settings/traces/traceutils.ts","./src/settings/types/index.ts","./src/teams/sharetoteammodal.test.tsx","./src/teams/sharetoteammodal.tsx","./src/teams/teamswitcher.tsx","./src/teams/accesssettings.ts","./src/teams/teamsslice.test.ts","./src/teams/teamsslice.ts","./src/upload/upload.test.tsx","./src/upload/upload.tsx","./src/upload/uploadslice.test.ts","./src/upload/uploadslice.ts","./src/upload/types/ingestor.test.ts","./src/upload/types/ingestor.ts","./src/utils/accessutils.test.ts","./src/utils/accessutils.ts","./src/utils/browserutils.ts","./src/utils/chartutils.test.ts","./src/utils/chartutils.ts","./src/utils/connectorauthutils.test.ts","./src/utils/connectorauthutils.ts","./src/utils/datetimeutils.test.ts","./src/utils/datetimeutils.ts","./src/utils/httpmethodcolors.test.ts","./src/utils/httpmethodcolors.ts","./src/utils/idempotency.ts","./src/utils/jwtutils.test.ts","./src/utils/jwtutils.ts","./src/utils/objectutils.ts","./src/utils/providerutils.ts","./src/utils/sourceutils.test.ts","./src/utils/sourceutils.ts","./src/utils/streamingstatusutils.test.ts","./src/utils/streamingstatusutils.ts","./src/utils/stringutils.ts","./src/utils/toolutils.test.ts","./src/utils/toolutils.ts"],"version":"6.0.3"}
\ No newline at end of file
diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts
new file mode 100644
index 00000000..089eeef9
--- /dev/null
+++ b/frontend/vite.config.d.ts
@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfigFnObject;
+export default _default;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 00000000..905b0af3
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,41 @@
+///
+import { defineConfig, loadEnv } from 'vite';
+import react from '@vitejs/plugin-react';
+import svgr from 'vite-plugin-svgr';
+import path from 'path';
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ return {
+ plugins: [react(), svgr()],
+ resolve: {
+ alias: {
+ '@': path.resolve(import.meta.dirname, './src'),
+ },
+ // Radix keeps its body pointer-events lock in module scope. Any second copy
+ // npm nests, now or after a future bump, can leave that lock stuck on .
+ dedupe: ['@radix-ui/react-dismissable-layer'],
+ },
+ server: {
+ // Extra dev hosts (e.g. a tailscale name) come from VITE_ALLOWED_HOSTS in
+ // an untracked .env.local; machine-specific names stay out of the repo.
+ allowedHosts: env.VITE_ALLOWED_HOSTS
+ ? env.VITE_ALLOWED_HOSTS.split(',')
+ .map((h) => h.trim())
+ .filter(Boolean)
+ : [],
+ // Use polling for file watching when running inside Docker.
+ // Native fs events do not propagate from Windows hosts into Linux
+ // containers, so Chokidar falls back to polling which works reliably.
+ watch: env.DOCKER
+ ? { usePolling: true, interval: 300 }
+ : undefined,
+ },
+ test: {
+ environment: 'happy-dom',
+ globals: true,
+ include: ['src/**/*.test.{ts,tsx}'],
+ setupFiles: ['./vitest.setup.ts'],
+ },
+ };
+});
From 68c1e12b6fef801e9a98047f04c35522991e9877 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 14:48:18 +0400
Subject: [PATCH 7/9] MCP rollback for connectors
---
.gitignore | 4 +
docsgpt/api/user/agents/routes.py | 70 ++++---
docsgpt/api/user/resource_access.py | 19 +-
docsgpt/api/user/tools/mcp.py | 82 +++-----
docsgpt/api/user/tools/routes.py | 34 ++++
docsgpt/api/user/workflows/routes.py | 45 +++++
docsgpt/storage/db/base_repository.py | 18 ++
frontend/src/locale/de.json | 2 +-
frontend/src/locale/en.json | 2 +-
frontend/src/locale/es.json | 2 +-
frontend/src/locale/jp.json | 2 +-
frontend/src/locale/ru.json | 2 +-
frontend/src/locale/zh-TW.json | 2 +-
frontend/src/locale/zh.json | 2 +-
frontend/src/modals/MCPServerModal.test.tsx | 8 +-
frontend/src/modals/MCPServerModal.tsx | 7 +-
frontend/src/settings/ToolConfig.test.tsx | 57 ++++++
frontend/src/settings/ToolConfig.tsx | 19 +-
frontend/src/settings/Tools.test.tsx | 10 +
frontend/src/settings/Tools.tsx | 13 +-
frontend/src/utils/toolUtils.test.ts | 21 ++
frontend/src/utils/toolUtils.ts | 12 ++
frontend/tsconfig.node.tsbuildinfo | 1 -
frontend/tsconfig.tsbuildinfo | 1 -
frontend/vite.config.d.ts | 2 -
frontend/vite.config.js | 41 ----
tests/api/user/agents/test_roles_access.py | 18 +-
tests/api/user/test_attach_owner_refs.py | 212 ++++++++++++++++++++
tests/api/user/test_tools_access.py | 102 +++++++++-
29 files changed, 650 insertions(+), 160 deletions(-)
delete mode 100644 frontend/tsconfig.node.tsbuildinfo
delete mode 100644 frontend/tsconfig.tsbuildinfo
delete mode 100644 frontend/vite.config.d.ts
delete mode 100644 frontend/vite.config.js
create mode 100644 tests/api/user/test_attach_owner_refs.py
diff --git a/.gitignore b/.gitignore
index 5bb2d60b..8cccab2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -158,6 +158,10 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/pnpm-debug.log*
frontend/lerna-debug.log*
+# tsc build output (composite tsconfig.node.json); Vite would load vite.config.js before .ts
+frontend/*.tsbuildinfo
+frontend/vite.config.js
+frontend/vite.config.d.ts
# Keep frontend utility helpers tracked (overrides global lib/ ignore)
!frontend/src/lib/
diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py
index 1711b039..47b1e3c7 100644
--- a/docsgpt/api/user/agents/routes.py
+++ b/docsgpt/api/user/agents/routes.py
@@ -25,7 +25,7 @@ from docsgpt.core.json_schema_utils import (
normalize_json_schema_payload,
)
from docsgpt.core.settings import settings
-from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.api.user.resource_access import (
AccessDenied,
agent_refs,
@@ -221,17 +221,17 @@ def _denied(err: AccessDenied):
return make_response(jsonify({"success": False, "message": err.message}), err.status)
-def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
- """Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
+def _tool_attachable(conn, tool_id: str, caller: str) -> bool:
+ """Whether ``caller`` may newly attach ``tool_id`` to an agent.
- Builtin synthetic ids belong to no one. Otherwise the tool must be the
- agent owner's (it runs with the owner's credentials) or reach the caller
- with ``use_in_own``.
+ Builtin synthetic ids belong to no one. Otherwise the caller must own the
+ tool or reach it with ``use_in_own``. The agent owner owning it is not
+ enough: an editor could otherwise wire the owner's private tool (run with
+ the owner's credentials) into an agent the editor controls.
Args:
conn: Open database connection.
tool_id: The tool id being attached.
- owner_id: The agent's owner.
caller: The user making the change.
Returns:
@@ -240,29 +240,26 @@ def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
tid = str(tool_id)
if is_synthesized_tool_id(tid):
return True
- if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
- return True
ra = resolve(conn, "tool", tid, caller)
return ra is not None and ra.can("use_in_own")
-def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
- """Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
+def _ref_attachable(conn, resource_type: str, resource_id: str, caller: str) -> bool:
+ """Whether ``caller`` may newly reference a source/prompt from an agent.
+
+ Like tools, the caller's own access counts, not the agent owner's.
Args:
conn: Open database connection.
resource_type: ``source`` or ``prompt``.
resource_id: The referenced id.
- owner_id: The agent's owner.
caller: The user making the change.
Returns:
- True when the agent owner owns it or the caller can ``use`` it.
+ True when the caller owns it or a team grant reaches them.
"""
if not resource_id:
return True
- if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
- return True
return can_access(conn, resource_type, str(resource_id), caller)
@@ -825,11 +822,11 @@ class CreateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
- extra_source_ids.append(src)
+ extra_source_ids.append(canonical_uuid(src))
else:
source_value = data.get("source", "")
if source_value and source_value != "default" and looks_like_uuid(source_value):
- source_id_resolved = source_value
+ source_id_resolved = canonical_uuid(source_value)
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
@@ -854,13 +851,17 @@ class CreateAgent(Resource):
# Tools run with the agent owner's credentials: attach only your
# own, or ones a team lets you use in your agents.
for tid in data.get("tools") or []:
- if not _tool_attachable(conn, tid, user, user):
+ if not _tool_attachable(conn, tid, user):
return make_response(
jsonify({"success": False, "message": "Tool not accessible"}),
403,
)
build_data = dict(data)
+ if isinstance(data.get("tools"), list):
+ build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
+ if looks_like_uuid(data.get("prompt_id")):
+ build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
build_data["source_id"] = source_id_resolved
@@ -1087,7 +1088,7 @@ class UpdateAgent(Resource):
if not source_id or source_id == "default":
update_fields["source_id"] = None
elif looks_like_uuid(source_id):
- update_fields["source_id"] = source_id
+ update_fields["source_id"] = canonical_uuid(source_id)
else:
return _reject(
f"Invalid source ID format: {source_id}", user, field
@@ -1102,7 +1103,7 @@ class UpdateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
- valid.append(src)
+ valid.append(canonical_uuid(src))
else:
return _reject(
f"Invalid source ID in list: {src}", user, field
@@ -1130,7 +1131,7 @@ class UpdateAgent(Resource):
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return _reject("Tools must be a list", user, field)
- update_fields["tools"] = tools_list
+ update_fields["tools"] = [canonical_uuid(t) for t in tools_list]
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
@@ -1250,13 +1251,25 @@ class UpdateAgent(Resource):
)
if wf_err:
return wf_err
+ # Only the owner may point the agent at a different
+ # workflow: editing rights on this agent extend to
+ # the graph it uses, so swapping in the workflow of
+ # another of the owner's agents would hand that
+ # graph to the editor.
+ current_workflow = existing_agent.get("workflow_id")
+ if is_team_editor and pg_workflow_id != (
+ str(current_workflow) if current_workflow else None
+ ):
+ return _denied(
+ AccessDenied(403, "Only the owner can change this agent's workflow")
+ )
update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
update_fields["prompt_id"] = None
elif looks_like_uuid(value):
- update_fields["prompt_id"] = value
+ update_fields["prompt_id"] = canonical_uuid(value)
else:
return _reject(f"Invalid prompt_id: {value}", user, field)
elif field == "allow_system_prompt_override":
@@ -1358,7 +1371,7 @@ class UpdateAgent(Resource):
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
- if not _ref_attachable(conn, "source", sid, owner_id, user):
+ if not _ref_attachable(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
@@ -1366,16 +1379,17 @@ class UpdateAgent(Resource):
if (
new_prompt_id
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
- and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
+ and not _ref_attachable(conn, "prompt", new_prompt_id, user)
):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}), 403
)
# Tools run with the OWNER's credentials (the agent-key path
# resolves and decrypts them as the owner), so a newly attached
- # tool must be the owner's or reach the caller with
- # ``use_in_own``. Tools already on the agent stay. Builtin
- # synthetic ids belong to no one and are always allowed.
+ # tool must be the caller's own or reach them with
+ # ``use_in_own`` -- the owner owning it is not enough. Tools
+ # already on the agent stay. Builtin synthetic ids belong to no
+ # one and are always allowed.
if "tools" in update_fields:
existing_tools = {
str(t) for t in (existing_agent.get("tools") or [])
@@ -1383,7 +1397,7 @@ class UpdateAgent(Resource):
for tid in update_fields["tools"] or []:
if str(tid) in existing_tools:
continue
- if not _tool_attachable(conn, tid, owner_id, user):
+ if not _tool_attachable(conn, tid, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py
index 35a19322..7d3f5627 100644
--- a/docsgpt/api/user/resource_access.py
+++ b/docsgpt/api/user/resource_access.py
@@ -27,7 +27,7 @@ from typing import Iterable, Optional
from sqlalchemy import Connection, text
-from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
@@ -73,7 +73,7 @@ ACTIONS: dict[str, dict[str, str]] = {
"use": "viewer", # see it and run it inside the owner's shared agents
"use_in_own": "viewer", # add it to my own agents and chats
"edit": "editor", # name, action descriptions, parameters, approval
- "edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
+ "edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
"share": "owner",
"delete": "owner",
"manage_settings": "owner",
@@ -186,14 +186,18 @@ def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
"""The resource's switches, defaults filled in."""
- return settings_many(conn, resource_type, [resource_id])[resource_id]
+ rid = canonical_uuid(str(resource_id))
+ return settings_many(conn, resource_type, [rid])[rid]
def settings_many(
conn: Connection, resource_type: str, resource_ids: Iterable[str]
) -> dict[str, dict[str, bool]]:
- """``resource_id -> switches`` for many resources in one query."""
- ids = [str(r) for r in resource_ids]
+ """``resource_id -> switches`` for many resources in one query.
+
+ Keys are canonical (lowercase) UUIDs, the form Postgres returns.
+ """
+ ids = [canonical_uuid(str(r)) for r in resource_ids]
out = {rid: default_settings(resource_type) for rid in ids}
uuids = [rid for rid in ids if looks_like_uuid(rid)]
if not uuids:
@@ -298,7 +302,10 @@ def resolve(
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None or not resource_id or not user_id:
return None
- owned = repo_cls(conn).get_any(str(resource_id), user_id)
+ # Postgres matches any casing but returns lowercase; canonicalise so the
+ # switch lookup (keyed by the returned id) can't miss.
+ resource_id = canonical_uuid(str(resource_id))
+ owned = repo_cls(conn).get_any(resource_id, user_id)
if owned is not None:
rid = str(owned.get("id") or resource_id)
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
diff --git a/docsgpt/api/user/tools/mcp.py b/docsgpt/api/user/tools/mcp.py
index bdc9281b..c28164f1 100644
--- a/docsgpt/api/user/tools/mcp.py
+++ b/docsgpt/api/user/tools/mcp.py
@@ -13,6 +13,7 @@ from docsgpt.api.user.tools.routes import (
_CREDENTIALS_FOR_NEW_SERVER,
_MCP_CREDENTIAL_AUTH_TYPES,
_mcp_host_changed,
+ check_oauth_mcp_owner_only,
denied_response,
transform_actions,
)
@@ -83,9 +84,6 @@ def _validate_mcp_server_url(config: dict) -> None:
raise ValueError(f"Invalid server URL: {exc}") from exc
-_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
-
-
def _existing_mcp_context(tool_id, user, config):
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
@@ -93,14 +91,16 @@ def _existing_mcp_context(tool_id, user, config):
the caller needs ``edit_credentials`` on that tool and everything runs as
its owner. Stored secrets are write-only, so an empty secret field reuses
the stored one while the host is unchanged; a new host never inherits them.
+ A server that is or would become OAuth is the owner's alone (its tokens
+ are the owner's sign-in).
Returns:
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
response (404 / 400) to return as is.
Raises:
- AccessDenied: the caller can't see the tool (404) or can't change
- its credentials (403).
+ AccessDenied: the caller can't see the tool (404), can't change its
+ credentials (403), or isn't the owner of an OAuth server (403).
"""
auth_credentials = _extract_auth_credentials(config)
if not tool_id:
@@ -113,6 +113,7 @@ def _existing_mcp_context(tool_id, user, config):
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
)
existing_config = existing_doc.get("config") or {}
+ check_oauth_mcp_owner_only(ra, existing_config, config)
moved = _mcp_host_changed(config, existing_config)
auth_type = config.get("auth_type", "none")
new_secret_keys = set(auth_credentials) - {"api_key_header"}
@@ -170,13 +171,7 @@ class TestMCPServerConfig(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
- _existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
- if not is_owner and config.get("auth_type") == "oauth":
- # An OAuth flow would store tokens under the editor's account
- # (and its popup event goes to that account), not the owner's.
- return make_response(
- jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
- )
+ _existing_doc, owner_id, _is_owner, _moved, auth_credentials = ctx
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
@@ -279,50 +274,16 @@ class MCPServerSave(Resource):
ctx = _existing_mcp_context(data.get("id"), user, config)
if not isinstance(ctx, tuple):
return ctx
- existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
- existing_config = (existing_doc or {}).get("config") or {}
+ existing_doc, owner_id, is_owner, _moved, merged_credentials = ctx
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
mcp_config["auth_credentials"] = merged_credentials
- keep_actions = False
if auth_type == "oauth":
- if config.get("oauth_task_id"):
- if not is_owner:
- # The OAuth flow stores tokens under the account that
- # ran it; reconnecting as the owner is owner-only.
- return make_response(
- jsonify({
- "success": False,
- "message": _ONLY_OWNER_RECONNECTS,
- }),
- 403,
- )
- redis_client = get_redis_instance()
- manager = MCPOAuthManager(redis_client)
- result = manager.get_oauth_status(
- config["oauth_task_id"], user
- )
- if not result.get("status") == "completed":
- return make_response(
- jsonify(
- {
- "success": False,
- "error": "OAuth failed or not completed. Please try authorizing again.",
- }
- ),
- 400,
- )
- actions_metadata = result.get("tools", [])
- elif (
- existing_doc is not None
- and not moved
- and existing_config.get("auth_type") == "oauth"
- ):
- # Editing an already-connected server: keep its tools.
- actions_metadata = existing_doc.get("actions") or []
- keep_actions = True
- else:
+ # Only the owner reaches here for an existing server (see
+ # ``_existing_mcp_context``), and every OAuth save needs the
+ # sign-in they just completed.
+ if not config.get("oauth_task_id"):
return make_response(
jsonify(
{
@@ -332,6 +293,22 @@ class MCPServerSave(Resource):
),
400,
)
+ redis_client = get_redis_instance()
+ manager = MCPOAuthManager(redis_client)
+ result = manager.get_oauth_status(
+ config["oauth_task_id"], user
+ )
+ if not result.get("status") == "completed":
+ return make_response(
+ jsonify(
+ {
+ "success": False,
+ "error": "OAuth failed or not completed. Please try authorizing again.",
+ }
+ ),
+ 400,
+ )
+ actions_metadata = result.get("tools", [])
elif auth_type == "none" or merged_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
mcp_tool.discover_tools()
@@ -356,8 +333,7 @@ class MCPServerSave(Resource):
"redirect_uri",
]:
storage_config.pop(field, None)
- # Kept actions already carry the owner's on/off and approval flags.
- transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
+ transformed_actions = transform_actions(actions_metadata)
display_name = data["displayName"]
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py
index 72cbd6c3..8528ba64 100644
--- a/docsgpt/api/user/tools/routes.py
+++ b/docsgpt/api/user/tools/routes.py
@@ -350,6 +350,37 @@ def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
+SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
+
+
+def check_oauth_mcp_owner_only(
+ ra: ResourceAccess, existing_config: Optional[dict], new_config: Optional[dict]
+) -> None:
+ """Keep a shared OAuth MCP server's connection with its owner.
+
+ MCP OAuth tokens are looked up by owner + server URL and a shared server
+ runs as its owner, so a grantee who moved an OAuth server, switched a
+ server to OAuth, or re-ran its sign-in would be using the owner's account
+ somewhere the owner never chose. Until connectors own OAuth accounts, any
+ connection change on a server that is (or would become) OAuth is
+ owner-only.
+
+ Args:
+ ra: The caller's access to the tool.
+ existing_config: The stored ``config``.
+ new_config: The incoming ``config``.
+
+ Raises:
+ AccessDenied: 403 when a non-owner touches an OAuth server's config.
+ """
+ if ra.access == "owner":
+ return
+ configs = [c if isinstance(c, dict) else {} for c in (existing_config, new_config)]
+ auth_types = {c.get("auth_type") for c in configs}
+ if "oauth" in auth_types:
+ raise AccessDenied(403, SHARED_OAUTH_OWNER_ONLY)
+
+
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
"""Validate-free merge of an incoming config with the stored one, as the owner.
@@ -776,6 +807,8 @@ class UpdateTool(Resource):
if "config" in data:
tool_name = tool_doc.get("name", data.get("name"))
existing_config = tool_doc.get("config", {}) or {}
+ if tool_name == "mcp_tool":
+ check_oauth_mcp_owner_only(ra, existing_config, data["config"])
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
data["config"], existing_config
):
@@ -873,6 +906,7 @@ class UpdateToolConfig(Resource):
tool_name = tool_doc.get("name")
if tool_name == "mcp_tool":
+ check_oauth_mcp_owner_only(ra, tool_doc.get("config"), data["config"])
server_url = (data["config"].get("server_url") or "").strip()
if server_url:
try:
diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py
index 1e48899a..3941e786 100644
--- a/docsgpt/api/user/workflows/routes.py
+++ b/docsgpt/api/user/workflows/routes.py
@@ -12,6 +12,7 @@ from docsgpt.agents.workflows.cel_evaluator import (
)
from docsgpt.api.user.resource_access import (
AccessDenied,
+ can_use_ref,
resolve,
sponsor_details,
sponsors_after_save,
@@ -124,6 +125,40 @@ def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
return refs
+def _new_node_ref_denied(
+ conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
+) -> Optional[AccessDenied]:
+ """403 for the first node tool/source ``caller`` newly adds but can't use.
+
+ A workflow runs as its owner, so an editor saving the owner's graph must
+ not reference the owner's private tools or sources: the caller's own
+ access counts (``use_in_own`` for a tool, ``use`` for a source), not the
+ owner's. Refs already in the stored graph stay, like an agent's.
+
+ Args:
+ conn: Open database connection.
+ previous_nodes: The stored graph's nodes, in builder shape.
+ new_nodes: The nodes being saved.
+ caller: The editor saving.
+
+ Returns:
+ An :class:`AccessDenied` to return, or None when every new ref is fine.
+ """
+ from docsgpt.agents.default_tools import is_synthesized_tool_id
+
+ existing = set(_node_refs(previous_nodes))
+ for resource_type, resource_id in _node_refs(new_nodes):
+ if (resource_type, resource_id) in existing:
+ continue
+ if resource_type == "tool" and is_synthesized_tool_id(resource_id):
+ continue
+ if resource_type == "source" and resource_id == "default":
+ continue
+ if not can_use_ref(conn, resource_type, resource_id, caller):
+ return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
+ return None
+
+
def _denied(err: AccessDenied):
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
return make_response(
@@ -639,6 +674,16 @@ class WorkflowDetail(Resource):
nodes_data = normalize_agent_node_json_schemas(nodes_data)
pg_workflow_id = str(workflow["id"])
current_graph_version = get_workflow_graph_version(workflow)
+ if acting != user_id:
+ previous_nodes = [
+ serialize_node(n)
+ for n in WorkflowNodesRepository(conn).find_by_version(
+ pg_workflow_id, current_graph_version,
+ )
+ ]
+ denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
+ if denied is not None:
+ return _denied(denied)
next_graph_version = current_graph_version + 1
_write_graph(
diff --git a/docsgpt/storage/db/base_repository.py b/docsgpt/storage/db/base_repository.py
index 3a8bee0d..9d92dbe3 100644
--- a/docsgpt/storage/db/base_repository.py
+++ b/docsgpt/storage/db/base_repository.py
@@ -35,6 +35,24 @@ def looks_like_uuid(value: Any) -> bool:
return isinstance(value, str) and bool(_UUID_RE.match(value))
+def canonical_uuid(value: Any) -> Any:
+ """The lowercase canonical form of a UUID string; anything else unchanged.
+
+ Postgres accepts any casing on ``CAST(... AS uuid)`` but returns the
+ lowercase form, so an id used as a dict key or stored in a JSON/array
+ column must be canonical to match what the database hands back.
+
+ Args:
+ value: A candidate id.
+
+ Returns:
+ ``str(UUID(value))`` for a UUID, else ``value`` as given.
+ """
+ if isinstance(value, UUID):
+ return str(value)
+ return str(UUID(value)) if looks_like_uuid(value) else value
+
+
def row_to_dict(row: Any) -> dict:
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 4232dde4..63bfedc4 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -1054,7 +1054,7 @@
"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."
+ "sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Verbindung dieses Servers ändern oder die Anmeldung neu verbinden. Du kannst ihn weiterhin umbenennen und seine Aktionen bearbeiten."
},
"configErrors": {
"required": "{{field}} ist erforderlich",
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 1d651430..3968ff98 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -1060,7 +1060,7 @@
"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."
+ "sharedOAuthOwnerOnly": "Only the owner can change this server's connection or reconnect its sign-in. You can still rename it and edit its actions."
},
"configErrors": {
"required": "{{field}} is required",
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index fec0c542..3b06f31e 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -1054,7 +1054,7 @@
"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."
+ "sharedOAuthOwnerOnly": "Solo el propietario puede cambiar la conexión de este servidor o volver a conectar su inicio de sesión. Aún puedes cambiarle el nombre y editar sus acciones."
},
"configErrors": {
"required": "{{field}} es obligatorio",
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index e065eb0d..94dd1bbf 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -1053,7 +1053,7 @@
"password": "パスワード"
},
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
- "sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
+ "sharedOAuthOwnerOnly": "このサーバーの接続を変更したり、サインインを再接続したりできるのはオーナーだけです。名前の変更とアクションの編集は引き続き行えます。"
},
"configErrors": {
"required": "{{field}}は必須です",
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index 85b1af45..8d1a61e2 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -1104,7 +1104,7 @@
"password": "пароль"
},
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
- "sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
+ "sharedOAuthOwnerOnly": "Изменить подключение этого сервера или переподключить вход может только владелец. Вы по-прежнему можете переименовать его и изменять его действия."
},
"configErrors": {
"required": "Поле «{{field}}» обязательно",
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index fc735107..ddbe792e 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -1053,7 +1053,7 @@
"password": "密碼"
},
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
- "sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
+ "sharedOAuthOwnerOnly": "只有擁有者可以變更此伺服器的連線或重新連結其登入。你仍然可以重新命名並編輯其動作。"
},
"configErrors": {
"required": "{{field}}為必填項",
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 393cb5d2..2bdc5001 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -1053,7 +1053,7 @@
"password": "密码"
},
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
- "sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
+ "sharedOAuthOwnerOnly": "只有所有者可以更改此服务器的连接或重新连接其登录。你仍然可以重命名它并编辑其操作。"
},
"configErrors": {
"required": "{{field}}为必填项",
diff --git a/frontend/src/modals/MCPServerModal.test.tsx b/frontend/src/modals/MCPServerModal.test.tsx
index d3a98311..b7a1c8c8 100644
--- a/frontend/src/modals/MCPServerModal.test.tsx
+++ b/frontend/src/modals/MCPServerModal.test.tsx
@@ -184,8 +184,7 @@ describe('MCPServerModal', () => {
expect(text()).toContain('Invalid server URL');
});
- it('lets an editor rename an OAuth tool without reconnecting it', async () => {
- saveMCPServer.mockReturnValue(json({ success: true }));
+ it('keeps a shared OAuth server read-only for an editor', async () => {
await render({
access: 'editor',
owner_label: 'Lena',
@@ -196,10 +195,9 @@ describe('MCPServerModal', () => {
expect(urlInput().disabled).toBe(true);
expect(button('settings.tools.mcp.testConnection')).toBeUndefined();
const save = button('settings.tools.mcp.save');
- expect(save.disabled).toBe(false);
+ expect(save.disabled).toBe(true);
await act(async () => save.click());
- expect(saveMCPServer).toHaveBeenCalledTimes(1);
- expect(testMCPConnection).not.toHaveBeenCalled();
+ expect(saveMCPServer).not.toHaveBeenCalled();
});
it('keeps OAuth reconnect for the owner', async () => {
diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx
index 975ec1c0..accc16c7 100644
--- a/frontend/src/modals/MCPServerModal.tsx
+++ b/frontend/src/modals/MCPServerModal.tsx
@@ -114,8 +114,9 @@ export default function MCPServerModal({
// 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.
+ // Only the owner can change or re-run an OAuth server's sign-in (the tokens
+ // are theirs), so the modal is read-only for a teammate. The Tools menu
+ // doesn't offer it to them; this guards any other way in.
const oauthOwnerOnly = isShared && server?.auth_type === 'oauth';
const savedSecret = SECRET_FIELDS[formData.auth_type];
const hasSavedSecret =
@@ -652,7 +653,7 @@ export default function MCPServerModal({
submitLabel={t('settings.tools.mcp.save')}
onSubmit={handleSave}
pending={loading}
- disabled={!saveActive && !oauthOwnerOnly}
+ disabled={!saveActive || oauthOwnerOnly}
/>
}
>
diff --git a/frontend/src/settings/ToolConfig.test.tsx b/frontend/src/settings/ToolConfig.test.tsx
index c91f233c..e9fb5756 100644
--- a/frontend/src/settings/ToolConfig.test.tsx
+++ b/frontend/src/settings/ToolConfig.test.tsx
@@ -183,6 +183,63 @@ describe('ToolConfig', () => {
expect(alert?.className).toContain('text-destructive');
});
+ describe('a shared MCP server', () => {
+ const mcpTool = (authType: string) =>
+ ({
+ id: 'mcp-1',
+ name: 'mcp_tool',
+ displayName: 'MCP',
+ description: '',
+ status: true,
+ access: 'editor',
+ allowed_actions: ['edit', 'edit_credentials', 'use', 'use_in_own'],
+ config: {
+ server_url: 'https://mcp.example.com/mcp',
+ auth_type: authType,
+ },
+ configRequirements: {
+ server_url: { type: 'string', label: 'Server URL', secret: false },
+ auth_type: { type: 'string', label: 'Auth', secret: false },
+ },
+ actions: [],
+ }) as unknown as UserToolType;
+
+ const rename = async () => {
+ const name = container.querySelector(
+ 'input[placeholder="settings.tools.customNamePlaceholder"]',
+ );
+ await act(async () => {
+ const setter = Object.getOwnPropertyDescriptor(
+ HTMLInputElement.prototype,
+ 'value',
+ )?.set;
+ setter?.call(name, 'Renamed');
+ name?.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ await act(async () => {
+ buttonByText('settings.tools.save')?.click();
+ });
+ };
+
+ it('locks the connection of an OAuth server and saves without it', async () => {
+ updateTool.mockResolvedValue({ ok: true });
+ await render(mcpTool('oauth'));
+ expect(container.querySelector('fieldset')?.disabled).toBe(true);
+ await rename();
+ expect(updateTool).toHaveBeenCalledTimes(1);
+ expect(updateTool.mock.calls[0][0]).not.toHaveProperty('config');
+ expect(updateTool.mock.calls[0][0].customName).toBe('Renamed');
+ });
+
+ it('still lets an editor change a non-OAuth server', async () => {
+ updateTool.mockResolvedValue({ ok: true });
+ await render(mcpTool('bearer'));
+ expect(container.querySelector('fieldset')?.disabled).toBe(false);
+ await rename();
+ expect(updateTool.mock.calls[0][0]).toHaveProperty('config');
+ });
+ });
+
it('renders the API tool header actions as outline-primary pills', async () => {
await render(apiTool);
for (const label of [
diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx
index fc7598d2..d9eeba9a 100644
--- a/frontend/src/settings/ToolConfig.tsx
+++ b/frontend/src/settings/ToolConfig.tsx
@@ -41,6 +41,7 @@ import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import { getMethodBadgeVariant } from '../utils/httpMethodColors';
import { can } from '../utils/accessUtils';
+import { isSharedOAuthMcp } from '../utils/toolUtils';
import { areObjectsEqual } from '../utils/objectUtils';
import { cn, focusRing } from '@/lib/utils';
import { APIActionType, APIToolType, UserToolType } from './types';
@@ -121,7 +122,10 @@ export default function ToolConfig({
>(new Set());
const { t } = useTranslation();
const canEdit = can(tool, 'edit');
- const canEditCredentials = can(tool, 'edit_credentials');
+ // A shared OAuth server's connection stays with its owner (the backend
+ // refuses it), so its fields lock like credentials the caller can't change.
+ const sharedOAuth = isSharedOAuthMcp(tool);
+ const canEditCredentials = can(tool, 'edit_credentials') && !sharedOAuth;
// Neither: the tool opens as a read-only view with no Save.
const readOnly = !canEdit && !canEditCredentials;
const access = React.useMemo(
@@ -274,7 +278,10 @@ export default function ToolConfig({
displayName: tool.displayName,
customName: customName,
description: tool.description,
- config: configToSave,
+ // Locked config isn't sent, so a rename or action edit still saves.
+ ...((canEditCredentials || tool.name === 'api_tool') && {
+ config: configToSave,
+ }),
actions: 'actions' in tool ? tool.actions : [],
status: tool.status,
},
@@ -398,7 +405,13 @@ export default function ToolConfig({
{readOnly && }
{!readOnly && !canEditCredentials && (
-
+
)}
{saveError && (
diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx
index 141d8b42..8421017e 100644
--- a/frontend/src/settings/Tools.test.tsx
+++ b/frontend/src/settings/Tools.test.tsx
@@ -183,6 +183,16 @@ describe('Tools', () => {
]);
});
+ it("hides Reconnect from an editor of an OAuth server (the sign-in is the owner's)", async () => {
+ const oauthConfig = { ...baseTool.config, auth_type: 'oauth' };
+ await render([
+ { ...editorTool, config: oauthConfig },
+ { ...ownTool, config: oauthConfig },
+ ]);
+ expect(menuLabels('ed')).toEqual(['settings.tools.edit']);
+ expect(menuLabels('own')).toContain('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']);
diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx
index 6e8a71b6..05dad368 100644
--- a/frontend/src/settings/Tools.tsx
+++ b/frontend/src/settings/Tools.tsx
@@ -26,7 +26,11 @@ import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import { can, isOwner, roleOf } from '../utils/accessUtils';
-import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
+import {
+ canAddToolToOwn,
+ isSharedOAuthMcp,
+ toolInChat,
+} from '../utils/toolUtils';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
import { APIToolType, UserToolType } from './types';
@@ -140,7 +144,12 @@ export default function Tools() {
variant: 'default',
},
];
- if (tool.name === 'mcp_tool' && can(tool, 'edit_credentials')) {
+ // A shared OAuth server's sign-in is the owner's to redo.
+ if (
+ tool.name === 'mcp_tool' &&
+ can(tool, 'edit_credentials') &&
+ !isSharedOAuthMcp(tool)
+ ) {
options.push({
icon: RefreshCw,
label: t('settings.tools.reconnect'),
diff --git a/frontend/src/utils/toolUtils.test.ts b/frontend/src/utils/toolUtils.test.ts
index a389cd72..76fe093c 100644
--- a/frontend/src/utils/toolUtils.test.ts
+++ b/frontend/src/utils/toolUtils.test.ts
@@ -6,6 +6,7 @@ import {
isChatPickerToolVisible,
isChatToolVisible,
isClassicAgentToolVisible,
+ isSharedOAuthMcp,
toolInChat,
} from './toolUtils';
@@ -88,6 +89,26 @@ describe('canAddToolToOwn', () => {
});
});
+describe('isSharedOAuthMcp', () => {
+ const oauth = { name: 'mcp_tool', config: { auth_type: 'oauth' } };
+
+ it('is true only for an OAuth MCP server shared with the caller', () => {
+ expect(isSharedOAuthMcp({ ...oauth, access: 'editor' })).toBe(true);
+ expect(isSharedOAuthMcp({ ...oauth, access: 'owner' })).toBe(false);
+ expect(isSharedOAuthMcp(oauth)).toBe(false);
+ expect(
+ isSharedOAuthMcp({
+ name: 'mcp_tool',
+ access: 'editor',
+ config: { auth_type: 'bearer' },
+ }),
+ ).toBe(false);
+ expect(
+ isSharedOAuthMcp({ ...oauth, name: 'api_tool', access: 'editor' }),
+ ).toBe(false);
+ });
+});
+
describe('isChatPickerToolVisible', () => {
it('hides shared tools the caller may not add to their own chats', () => {
expect(
diff --git a/frontend/src/utils/toolUtils.ts b/frontend/src/utils/toolUtils.ts
index 3515fd07..36370281 100644
--- a/frontend/src/utils/toolUtils.ts
+++ b/frontend/src/utils/toolUtils.ts
@@ -51,6 +51,18 @@ export const toolInChat = (tool: {
export const canAddToolToOwn = (tool: AccessFields): boolean =>
isOwner(tool) || can(tool, 'use_in_own');
+/**
+ * A team-shared MCP server that signs in with OAuth. Its connection (URL,
+ * auth, sign-in) is the owner's alone, since the tokens are the owner's
+ * account; the backend refuses any change from anyone else.
+ */
+export const isSharedOAuthMcp = (
+ tool: AccessFields & { name?: string; config?: unknown },
+): boolean =>
+ tool.name === 'mcp_tool' &&
+ !isOwner(tool) &&
+ (tool.config as { auth_type?: string } | undefined)?.auth_type === 'oauth';
+
// Composer Tools picker: the chat-popup rule, minus shared tools the caller
// can't turn on for their own chats.
export const isChatPickerToolVisible = (
diff --git a/frontend/tsconfig.node.tsbuildinfo b/frontend/tsconfig.node.tsbuildinfo
deleted file mode 100644
index 4bd24cc2..00000000
--- a/frontend/tsconfig.node.tsbuildinfo
+++ /dev/null
@@ -1 +0,0 @@
-{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.es2025.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.es2025.collection.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.es2025.intl.d.ts","./node_modules/typescript/lib/lib.es2025.iterator.d.ts","./node_modules/typescript/lib/lib.es2025.promise.d.ts","./node_modules/typescript/lib/lib.es2025.regexp.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2025.full.d.ts","./node_modules/vitest/optional-runtime-types.d.ts","./node_modules/tinybench/dist/index.d.ts","./node_modules/vitest/dist/chunks/config.d.cu_b-wjj.d.ts","./node_modules/vite/types/hmrpayload.d.ts","./node_modules/vite/dist/node/chunks/modulerunnertransport.d.ts","./node_modules/vite/types/customevent.d.ts","./node_modules/vite/types/hot.d.ts","./node_modules/vite/dist/node/module-runner.d.ts","./node_modules/vitest/dist/chunks/rpc.d.da9utv4e.d.ts","./node_modules/vitest/dist/chunks/environment.d.c6xyahwa.d.ts","./node_modules/vitest/dist/chunks/worker.d.mlmnzoje.d.ts","./node_modules/vitest/dist/chunks/browser.d.g5thl309.d.ts","./node_modules/vitest/dist/chunks/task-utils.d.bzm4gsqd.d.ts","./node_modules/@vitest/mocker/dist/registry.d-xlx_foyf.d.ts","./node_modules/@vitest/mocker/dist/index.d-d4wtotqw.d.ts","./node_modules/@vitest/mocker/dist/index.d.ts","./node_modules/vitest/dist/chunks/evaluatedmodules.d.bxj5omdx.d.ts","./node_modules/expect-type/dist/utils.d.ts","./node_modules/expect-type/dist/overloads.d.ts","./node_modules/expect-type/dist/branding.d.ts","./node_modules/expect-type/dist/messages.d.ts","./node_modules/expect-type/dist/index.d.ts","./node_modules/@types/deep-eql/index.d.ts","./node_modules/assertion-error/index.d.ts","./node_modules/@types/chai/index.d.ts","./node_modules/vitest/dist/index.d.ts","./node_modules/@types/node/compatibility/disposable.d.ts","./node_modules/@types/node/compatibility/indexable.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/compatibility/index.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/rolldown/dist/shared/logging-xuho4may.d.mts","./node_modules/@oxc-project/types/types.d.ts","./node_modules/rolldown/dist/shared/binding-dzunhvw4.d.mts","./node_modules/@rolldown/pluginutils/dist/filter/index.d.mts","./node_modules/@rolldown/pluginutils/dist/index.d.mts","./node_modules/rolldown/dist/shared/define-config-djhybh6s.d.mts","./node_modules/rolldown/dist/index.d.mts","./node_modules/rolldown/dist/parse-ast-index.d.mts","./node_modules/vite/types/internal/rolluptypecompat.d.ts","./node_modules/rolldown/dist/shared/constructors-bnig8fec.d.mts","./node_modules/rolldown/dist/plugins-index.d.mts","./node_modules/rolldown/dist/shared/transform-cvse9_mh.d.mts","./node_modules/rolldown/dist/utils-index.d.mts","./node_modules/vite/types/internal/devtoolsoptions.d.ts","./node_modules/vite/types/internal/esbuildoptions.d.ts","./node_modules/vite/types/metadata.d.ts","./node_modules/rolldown/dist/experimental-index.d.mts","./node_modules/vite/types/internal/terseroptions.d.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/vite/types/internal/csspreprocessoroptions.d.ts","./node_modules/vite/node_modules/lightningcss/node/ast.d.ts","./node_modules/vite/node_modules/lightningcss/node/targets.d.ts","./node_modules/vite/node_modules/lightningcss/node/index.d.ts","./node_modules/vite/types/internal/lightningcssoptions.d.ts","./node_modules/rolldown/dist/filter-index.d.mts","./node_modules/vite/types/importglob.d.ts","./node_modules/vite/dist/node/index.d.ts","./node_modules/@vitejs/plugin-react/types/optionaltypes.d.ts","./node_modules/@vitejs/plugin-react/dist/index.d.ts","./node_modules/@types/estree/index.d.ts","./node_modules/@rollup/pluginutils/types/index.d.ts","./node_modules/prettier/doc.d.ts","./node_modules/prettier/index.d.ts","./node_modules/@svgr/babel-plugin-transform-svg-component/dist/index.d.ts","./node_modules/@svgr/babel-preset/dist/index.d.ts","./node_modules/@svgr/core/dist/index.d.ts","./node_modules/vite-plugin-svgr/dist/index.d.ts","./vite.config.ts"],"fileIdsList":[[114,163,180,181],[114,163,180,181,217],[114,163,180,181,262],[114,163,180,181,266],[114,163,180,181,265,267],[105,106,114,163,180,181],[114,160,161,163,180,181],[114,162,163,180,181],[163,180,181],[114,163,168,180,181,198],[114,163,164,169,174,180,181,183,195,206],[114,163,164,165,174,180,181,183],[109,110,111,114,163,180,181],[114,163,166,180,181,207],[114,163,167,168,175,180,181,184],[114,163,168,180,181,195,203],[114,163,169,171,174,180,181,183],[114,162,163,170,180,181],[114,163,171,172,180,181],[114,163,173,174,180,181],[114,162,163,174,180,181],[114,163,174,175,176,180,181,195,206],[114,163,174,175,176,180,181,190,195,198],[114,155,163,171,174,177,180,181,183,195,206],[114,163,174,175,177,178,180,181,183,195,203,206],[114,163,177,179,180,181,195,203,206],[112,113,114,115,116,117,118,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212],[114,163,174,180,181],[114,163,180,181,182,206],[114,163,171,174,180,181,183,195],[114,163,180,181,184],[114,163,180,181,185],[114,162,163,180,181,186],[114,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212],[114,163,180,181,188],[114,163,180,181,189],[114,163,174,180,181,190,191],[114,163,180,181,190,192,207,209],[114,163,175,180,181],[114,163,174,180,181,195,196,198],[114,163,180,181,197,198],[114,163,180,181,195,196],[114,163,180,181,198],[114,163,180,181,199],[114,160,163,180,181,195,200,206],[114,163,174,180,181,201,202],[114,163,180,181,201,202],[114,163,168,180,181,183,195,203],[114,163,180,181,204],[114,163,180,181,183,205],[114,163,177,180,181,189,206],[114,163,168,180,181,207],[114,163,180,181,195,208],[114,163,180,181,182,209],[114,163,180,181,210],[114,155,163,180,181],[114,155,163,174,176,180,181,186,195,198,206,208,209,211],[114,163,180,181,195,212],[114,163,180,181,259,260],[96,114,163,180,181],[96,97,114,163,180,181],[100,101,114,163,180,181],[100,101,102,103,114,163,180,181],[100,102,114,163,180,181],[100,114,163,180,181],[114,163,180,181,247],[114,163,180,181,245,247],[114,163,180,181,236,244,245,246,248,250],[114,163,180,181,234],[114,163,180,181,237,242,247,250],[114,163,180,181,233,250],[114,163,180,181,237,238,241,242,243,250],[114,163,180,181,237,238,239,241,242,250],[114,163,180,181,234,235,236,237,238,242,243,244,246,247,248,250],[114,163,180,181,250],[114,163,180,181,232,234,235,236,237,238,239,241,242,243,244,245,246,247,248,249],[114,163,180,181,232,250],[114,163,180,181,237,239,240,242,243,250],[114,163,180,181,241,250],[114,163,180,181,242,243,247,250],[114,163,180,181,235,245],[114,163,180,181,264],[114,163,180,181,216,219,223,225],[114,163,180,181,219],[114,163,180,181,214,216,219],[114,163,180,181,215,216],[114,163,180,181,216,219,223],[114,163,180,181,215],[114,163,180,181,216,219],[114,163,180,181,214,215,216,218],[114,163,180,181,214,216],[114,163,180,181,215,216,225],[114,127,131,163,180,181,206],[114,127,163,180,181,195,206],[114,122,163,180,181],[114,124,127,163,180,181,203,206],[114,163,180,181,183,203],[114,163,180,181,213],[114,122,163,180,181,213],[114,124,127,163,180,181,183,206],[114,119,120,123,126,163,174,180,181,195,206],[114,127,134,163,180,181],[114,119,125,163,180,181],[114,127,148,149,163,180,181],[114,123,127,163,180,181,198,206,213],[114,148,163,180,181,213],[114,121,122,163,180,181,213],[114,127,163,180,181],[114,121,122,123,124,125,126,127,128,129,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,149,150,151,152,153,154,163,180,181],[114,127,142,163,180,181],[114,127,134,135,163,180,181],[114,125,127,135,136,163,180,181],[114,126,163,180,181],[114,119,122,127,163,180,181],[114,127,131,135,136,163,180,181],[114,131,163,180,181],[114,125,127,130,163,180,181,206],[114,119,124,127,134,163,180,181],[114,163,180,181,195],[114,122,127,148,163,180,181,211,213],[114,163,180,181,259,263,268],[86,114,163,180,181],[86,87,88,90,114,163,174,175,177,178,179,180,181,183,195,203,206,212,213,220,221,222,224,226,227,228,229,230,231,251,252,256,257,258,259],[86,87,88,89,114,163,180,181],[114,163,180,181,253,254],[88,114,163,180,181],[114,163,180,181,255],[114,163,180,181,220,229,259],[114,163,180,181,220,259],[85,91,93,114,163,180,181],[83,84,114,163,180,181],[85,114,163,180,181],[90,114,163,180,181],[85,90,114,163,180,181],[85,108,114,163,180,181],[85,90,91,92,114,163,180,181],[83,84,85,90,91,92,93,94,95,98,99,104,107,114,163,180,181],[108,114,163,180,181,185,259,261,269]],"fileInfos":[{"version":"bcd24271a113971ba9eb71ff8cb01bc6b0f872a85c23fdbe5d93065b375933cd","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f88bedbeb09c6f5a6645cb24c7c55f1aa22d19ae96c8e6959cbd8b85a707bc6","impliedFormat":1},{"version":"7fe93b39b810eadd916be8db880dd7f0f7012a5cc6ffb62de8f62a2117fa6f1f","impliedFormat":1},{"version":"bb0074cc08b84a2374af33d8bf044b80851ccc9e719a5e202eacf40db2c31600","impliedFormat":1},{"version":"1a7daebe4f45fb03d9ec53d60008fbf9ac45a697fdc89e4ce218bc94b94f94d6","impliedFormat":1},{"version":"f94b133a3cb14a288803be545ac2683e0d0ff6661bcd37e31aaaec54fc382aed","impliedFormat":1},{"version":"f59d0650799f8782fd74cf73c19223730c6d1b9198671b1c5b3a38e1188b5953","impliedFormat":1},{"version":"8a15b4607d9a499e2dbeed9ec0d3c0d7372c850b2d5f1fb259e8f6d41d468a84","impliedFormat":1},{"version":"26e0fe14baee4e127f4365d1ae0b276f400562e45e19e35fd2d4c296684715e6","impliedFormat":1},{"version":"1e9332c23e9a907175e0ffc6a49e236f97b48838cc8aec9ce7e4cec21e544b65","impliedFormat":1},{"version":"3753fbc1113dc511214802a2342280a8b284ab9094f6420e7aa171e868679f91","impliedFormat":1},{"version":"999ca32883495a866aa5737fe1babc764a469e4cde6ee6b136a4b9ae68853e4b","impliedFormat":1},{"version":"d6b1eba8496bdd0eed6fc8a685768fe01b2da4a0388b5fe7df558290bffcf32f","affectsGlobalScope":true,"impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"7f57fc4404ff020bc45b9c620aff2b40f700b95fe31164024c453a5e3c163c54","impliedFormat":1},{"version":"2a2de5b9459b3fc44decd9ce6100b72f1b002ef523126c1d3d8b2a4a63d74d78","affectsGlobalScope":true,"impliedFormat":1},{"version":"f13f4b465c99041e912db5c44129a94588e1aafee35a50eab51044833f50b4ee","affectsGlobalScope":true,"impliedFormat":1},{"version":"eadcffda2aa84802c73938e589b9e58248d74c59cb7fcbca6474e3435ac15504","affectsGlobalScope":true,"impliedFormat":1},{"version":"105ba8ff7ba746404fe1a2e189d1d3d2e0eb29a08c18dded791af02f29fb4711","affectsGlobalScope":true,"impliedFormat":1},{"version":"00343ca5b2e3d48fa5df1db6e32ea2a59afab09590274a6cccb1dbae82e60c7c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ebd9f816d4002697cb2864bea1f0b70a103124e18a8cd9645eeccc09bdf80ab4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2c1afac30a01772cd2a9a298a7ce7706b5892e447bb46bdbeef720f7b5da77ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"7b0225f483e4fa685625ebe43dd584bb7973bbd84e66a6ba7bbe175ee1048b4f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c0a4b8ac6ce74679c1da2b3795296f5896e31c38e888469a8e0f99dc3305de60","affectsGlobalScope":true,"impliedFormat":1},{"version":"3084a7b5f569088e0146533a00830e206565de65cae2239509168b11434cd84f","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5079c53f0f141a0698faa903e76cb41cd664e3efb01cc17a5c46ec2eb0bef42","affectsGlobalScope":true,"impliedFormat":1},{"version":"32cafbc484dea6b0ab62cf8473182bbcb23020d70845b406f80b7526f38ae862","affectsGlobalScope":true,"impliedFormat":1},{"version":"fca4cdcb6d6c5ef18a869003d02c9f0fd95df8cfaf6eb431cd3376bc034cad36","affectsGlobalScope":true,"impliedFormat":1},{"version":"b93ec88115de9a9dc1b602291b85baf825c85666bf25985cc5f698073892b467","affectsGlobalScope":true,"impliedFormat":1},{"version":"f5c06dcc3fe849fcb297c247865a161f995cc29de7aa823afdd75aaaddc1419b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b77e16112127a4b169ef0b8c3a4d730edf459c5f25fe52d5e436a6919206c4d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"fbffd9337146eff822c7c00acbb78b01ea7ea23987f6c961eba689349e744f8c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a995c0e49b721312f74fdfb89e4ba29bd9824c770bbb4021d74d2bf560e4c6bd","affectsGlobalScope":true,"impliedFormat":1},{"version":"c7b3542146734342e440a84b213384bfa188835537ddbda50d30766f0593aff9","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce6180fa19b1cccd07ee7f7dbb9a367ac19c0ed160573e4686425060b6df7f57","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f02e2476bccb9dbe21280d6090f0df17d2f66b74711489415a8aa4df73c9675","affectsGlobalScope":true,"impliedFormat":1},{"version":"45e3ab34c1c013c8ab2dc1ba4c80c780744b13b5676800ae2e3be27ae862c40c","affectsGlobalScope":true,"impliedFormat":1},{"version":"805c86f6cca8d7702a62a844856dbaa2a3fd2abef0536e65d48732441dde5b5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"e42e397f1a5a77994f0185fd1466520691456c772d06bf843e5084ceb879a0ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"f4c2b41f90c95b1c532ecc874bd3c111865793b23aebcc1c3cbbabcd5d76ffb0","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab26191cfad5b66afa11b8bf935ef1cd88fabfcb28d30b2dfa6fad877d050332","affectsGlobalScope":true,"impliedFormat":1},{"version":"2088bc26531e38fb05eedac2951480db5309f6be3fa4a08d2221abb0f5b4200d","affectsGlobalScope":true,"impliedFormat":1},{"version":"cb9d366c425fea79716a8fb3af0d78e6b22ebbab3bd64d25063b42dc9f531c1e","affectsGlobalScope":true,"impliedFormat":1},{"version":"500934a8089c26d57ebdb688fc9757389bb6207a3c8f0674d68efa900d2abb34","affectsGlobalScope":true,"impliedFormat":1},{"version":"689da16f46e647cef0d64b0def88910e818a5877ca5379ede156ca3afb780ac3","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc21cc8b6fee4f4c2440d08035b7ea3c06b3511314c8bab6bef7a92de58a2593","affectsGlobalScope":true,"impliedFormat":1},{"version":"7ca53d13d2957003abb47922a71866ba7cb2068f8d154877c596d63c359fed25","affectsGlobalScope":true,"impliedFormat":1},{"version":"54725f8c4df3d900cb4dac84b64689ce29548da0b4e9b7c2de61d41c79293611","affectsGlobalScope":true,"impliedFormat":1},{"version":"e5594bc3076ac29e6c1ebda77939bc4c8833de72f654b6e376862c0473199323","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f3eb332c2d73e729f3364fcc0c2b375e72a121e8157d25a82d67a138c83a95c","affectsGlobalScope":true,"impliedFormat":1},{"version":"6f4427f9642ce8d500970e4e69d1397f64072ab73b97e476b4002a646ac743b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"48915f327cd1dea4d7bd358d9dc7732f58f9e1626a29cc0c05c8c692419d9bb7","affectsGlobalScope":true,"impliedFormat":1},{"version":"b7bf9377723203b5a6a4b920164df22d56a43f593269ba6ae1fdc97774b68855","affectsGlobalScope":true,"impliedFormat":1},{"version":"db9709688f82c9e5f65a119c64d835f906efe5f559d08b11642d56eb85b79357","affectsGlobalScope":true,"impliedFormat":1},{"version":"4b25b8c874acd1a4cf8444c3617e037d444d19080ac9f634b405583fd10ce1f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"37be57d7c90cf1f8112ee2636a068d8fd181289f82b744160ec56a7dc158a9f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a917a49ac94cd26b754ab84e113369a75d1a47a710661d7cd25e961cc797065f","affectsGlobalScope":true,"impliedFormat":1},{"version":"6d3261badeb7843d157ef3e6f5d1427d0eeb0af0cf9df84a62cfd29fd47ac86e","affectsGlobalScope":true,"impliedFormat":1},{"version":"195daca651dde22f2167ac0d0a05e215308119a3100f5e6268e8317d05a92526","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b11e4285cd2bb164a4dc09248bdec69e9842517db4ca47c1ba913011e44ff2f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0508571a52475e245b02bc50fa1394065a0a3d05277fbf5120c3784b85651799","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f9af488f510c3015af3cc8c267a9e9d96c4dd38a1fdff0e11dc5a544711415b","affectsGlobalScope":true,"impliedFormat":1},{"version":"fc611fea8d30ea72c6bbfb599c9b4d393ce22e2f5bfef2172534781e7d138104","affectsGlobalScope":true,"impliedFormat":1},{"version":"0bd714129fca875f7d4c477a1a392200b0bcd13fb2e80928cd334b63830ea047","affectsGlobalScope":true,"impliedFormat":1},{"version":"e2c9037ae6cd2c52d80ceef0b3c5ffdb488627d71529cf4f63776daf11161c9a","affectsGlobalScope":true,"impliedFormat":1},{"version":"135d5cf4d345f59f1a9caadfafcd858d3d9cc68290db616cc85797224448cccc","affectsGlobalScope":true,"impliedFormat":1},{"version":"bc238c3f81c2984751932b6aab223cd5b830e0ac6cad76389e5e9d2ffc03287d","affectsGlobalScope":true,"impliedFormat":1},{"version":"4a07f9b76d361f572620927e5735b77d6d2101c23cdd94383eb5b706e7b36357","affectsGlobalScope":true,"impliedFormat":1},{"version":"7c4e8dc6ab834cc6baa0227e030606d29e3e8449a9f67cdf5605ea5493c4db29","affectsGlobalScope":true,"impliedFormat":1},{"version":"de7ba0fd02e06cd9a5bd4ab441ed0e122735786e67dde1e849cced1cd8b46b78","affectsGlobalScope":true,"impliedFormat":1},{"version":"6148e4e88d720a06855071c3db02069434142a8332cf9c182cda551adedf3156","affectsGlobalScope":true,"impliedFormat":1},{"version":"d63dba625b108316a40c95a4425f8d4294e0deeccfd6c7e59d819efa19e23409","affectsGlobalScope":true,"impliedFormat":1},{"version":"0568d6befee03dd435bed4fc25c4e46865b24bdcb8c563fdc21f580a2c301904","affectsGlobalScope":true,"impliedFormat":1},{"version":"30d62269b05b584741f19a5369852d5d34895aa2ac4fd948956f886d15f9cc0d","affectsGlobalScope":true,"impliedFormat":1},{"version":"f128dae7c44d8f35ee42e0a437000a57c9f06cc04f8b4fb42eebf44954d53dc8","affectsGlobalScope":true,"impliedFormat":1},{"version":"ffbe6d7b295306b2ba88030f65b74c107d8d99bdcf596ea99c62a02f606108b0","affectsGlobalScope":true,"impliedFormat":1},{"version":"996fb27b15277369c68a4ba46ed138b4e9e839a02fb4ec756f7997629242fd9f","affectsGlobalScope":true,"impliedFormat":1},{"version":"79b712591b270d4778c89706ca2cfc56ddb8c3f895840e477388f1710dc5eda9","affectsGlobalScope":true,"impliedFormat":1},{"version":"20884846cef428b992b9bd032e70a4ef88e349263f63aeddf04dda837a7dba26","affectsGlobalScope":true,"impliedFormat":1},{"version":"1ce14b81c5cc821994aa8ec1d42b220dd41b27fcc06373bce3958af7421b77d4","affectsGlobalScope":true,"impliedFormat":1},{"version":"b3a048b3e9302ef9a34ef4ebb9aecfb28b66abb3bce577206a79fee559c230da","affectsGlobalScope":true,"impliedFormat":1},{"version":"e03da518b01b46a4c99a1f88cd727ee98ddf14492c43dae1ae7a63e992971bab","impliedFormat":1},{"version":"c76c02846ba7d40b9b3488f0e8d75d02cbdee2f0bc5fcd55dd3bd2e1457646ea","impliedFormat":99},{"version":"883ab61941ae697bd5724f02abc0a4044dc5427e7fb002c95867204918c1a68c","impliedFormat":99},{"version":"2493d114a1f01f02d69b4b44265405dfd5bbf135e0f467838808c4137c71ce54","impliedFormat":99},{"version":"24c010698f5f7a0f1c216b963994d09ecfef77d7d5b4116b248068287ddb5964","impliedFormat":99},{"version":"c911b3bd083dade8fdb7775261aa25feae695d562a3b11209650b47be90043fc","impliedFormat":99},{"version":"5ef1bf71c773ca3df07f0c8c126af004a8e7310a7373ba4f049e7d3fb1b21921","impliedFormat":99},{"version":"4e003c868b0d8f8ad200b96cbc653e18e513fa23e1c19c4fe3cc25d4394efc47","impliedFormat":99},{"version":"93aed5bd196d1a1417d07e5c5b66a96107f0ee7d5c9027c2af12d876ab5e0f5e","impliedFormat":99},{"version":"fc620fc5a05d1d8499e790626f83b401604634dfd9c4b11042099a63883df8fb","impliedFormat":99},{"version":"123b46e873f6ae5902094478c2c58b725b3d92e1d36b53daf85b7732d032dfa8","impliedFormat":99},{"version":"11f54bcce4d6e4271122c6b06329b37f6d297141ad6e918b5181e17572158a10","impliedFormat":99},{"version":"dd8cb59c27a49fdc4b86eb755ddaaab5de0f30a627767f506fb2607ca4b2ec22","impliedFormat":99},{"version":"9500d6fd10c59daeab4a18b1d9aeaf13d790bd4a92a5f864d5f323dba395d4cc","impliedFormat":99},{"version":"8d4ea2431523a42682a9f2efb2ae662aa42a39983c9d40a19257b2445f50450e","impliedFormat":99},{"version":"29738162d88967e51557276cba80552cb8d2a4c823873b7f1531244860f0d178","impliedFormat":99},{"version":"fc07122b30042636bbf689305c75a764d6e3c2eeb75cd1033c96a6a7dc9e6e3f","impliedFormat":99},{"version":"b413fbc6658fe2774f8bf9a15cf4c53e586fc38a2d5256b3b9647da242c14389","impliedFormat":99},{"version":"dd51e53752b310bd20c9b1a87bbf12b1fe2be7fe40f505b43199496481096275","impliedFormat":1},{"version":"a87be4662442b3feeffc331ecafe6b36cafd08727e2d7f2425a5099577e7fd18","impliedFormat":1},{"version":"cd4cd9220a1ba793bc935e76d8e5481c110a90d9868ae7866a182ee71cdb6abb","impliedFormat":1},{"version":"0a7fb8619b10bc05fd933ca9ac1c8b2ab2220be7a57b57565c3ac158595494ef","impliedFormat":1},{"version":"c4a5f91feb9c5a6b2a91089d959c38391b79a961db3b9cc73b8877d57ad7dcdc","impliedFormat":1},{"version":"427fe2004642504828c1476d0af4270e6ad4db6de78c0b5da3e4c5ca95052a99","impliedFormat":1},{"version":"2eeffcee5c1661ddca53353929558037b8cf305ffb86a803512982f99bcab50d","impliedFormat":99},{"version":"9afb4cb864d297e4092a79ee2871b5d3143ea14153f62ef0bb04ede25f432030","affectsGlobalScope":true,"impliedFormat":99},{"version":"3589cb90a45acfb8c2336b34a9e340f036a6aecfdb71742047d37748dfa52816","impliedFormat":99},{"version":"6c7176368037af28cb72f2392010fa1cef295d6d6744bca8cfb54985f3a18c3e","affectsGlobalScope":true,"impliedFormat":1},{"version":"ab41ef1f2cdafb8df48be20cd969d875602483859dc194e9c97c8a576892c052","affectsGlobalScope":true,"impliedFormat":1},{"version":"437e20f2ba32abaeb7985e0afe0002de1917bc74e949ba585e49feba65da6ca1","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"98cffbf06d6bab333473c70a893770dbe990783904002c4f1a960447b4b53dca","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"808069bba06b6768b62fd22429b53362e7af342da4a236ed2d2e1c89fcca3b4a","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"b52476feb4a0cbcb25e5931b930fc73cb6643fb1a5060bf8a3dda0eeae5b4b68","affectsGlobalScope":true,"impliedFormat":1},{"version":"f9501cc13ce624c72b61f12b3963e84fad210fbdf0ffbc4590e08460a3f04eba","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"0fa06ada475b910e2106c98c68b10483dc8811d0c14a8a8dd36efb2672485b29","impliedFormat":1},{"version":"33e5e9aba62c3193d10d1d33ae1fa75c46a1171cf76fef750777377d53b0303f","impliedFormat":1},{"version":"2b06b93fd01bcd49d1a6bd1f9b65ddcae6480b9a86e9061634d6f8e354c1468f","impliedFormat":1},{"version":"6a0cd27e5dc2cfbe039e731cf879d12b0e2dded06d1b1dedad07f7712de0d7f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"13f5c844119c43e51ce777c509267f14d6aaf31eafb2c2b002ca35584cd13b29","impliedFormat":1},{"version":"e60477649d6ad21542bd2dc7e3d9ff6853d0797ba9f689ba2f6653818999c264","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"4c829ab315f57c5442c6667b53769975acbf92003a66aef19bce151987675bd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"b2ade7657e2db96d18315694789eff2ddd3d8aea7215b181f8a0b303277cc579","impliedFormat":1},{"version":"78dbea00e90d2df8ea3dbef0cc379d95b8be9b71cd6bde4c28728f306811803b","impliedFormat":1},{"version":"4d631b81fa2f07a0e63a9a143d6a82c25c5f051298651a9b69176ba28930756d","impliedFormat":1},{"version":"836a356aae992ff3c28a0212e3eabcb76dd4b0cc06bcb9607aeef560661b860d","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"41670ee38943d9cbb4924e436f56fc19ee94232bc96108562de1a734af20dc2c","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e1e46d0a9837ee058c100501080c920fa98081ea3956af0374308ba6f22a33e","impliedFormat":1},{"version":"272ca407e0c9068bdc5152552d876e68037ceae3de62e529306403e973dec8e1","impliedFormat":1},{"version":"fa7834c715d5357e4540cee40ce96c3250ddb67a7b879a6b7fa0e86d6696f121","impliedFormat":1},{"version":"22dfb07a7ab15b66ac043829056fe70124844636ae719551812ac631ba04985b","impliedFormat":1},{"version":"a10f0e1854f3316d7ee437b79649e5a6ae3ae14ffe6322b02d4987071a95362e","impliedFormat":1},{"version":"e208f73ef6a980104304b0d2ca5f6bf1b85de6009d2c7e404028b875020fa8f2","impliedFormat":1},{"version":"d163b6bc2372b4f07260747cbc6c0a6405ab3fbcea3852305e98ac43ca59f5bc","impliedFormat":1},{"version":"e6fa9ad47c5f71ff733744a029d1dc472c618de53804eae08ffc243b936f87ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"a6f137d651076822d4fe884287e68fd61785a0d3d1fdb250a5059b691fa897db","impliedFormat":1},{"version":"24826ed94a78d5c64bd857570fdbd96229ad41b5cb654c08d75a9845e3ab7dde","impliedFormat":1},{"version":"8b479a130ccb62e98f11f136d3ac80f2984fdc07616516d29881f3061f2dd472","impliedFormat":1},{"version":"928af3d90454bf656a52a48679f199f64c1435247d6189d1caf4c68f2eaf921f","affectsGlobalScope":true,"impliedFormat":1},{"version":"bceb58df66ab8fb00170df20cd813978c5ab84be1d285710c4eb005d8e9d8efb","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"4f9d8ca0c417b67b69eeb54c7ca1bedd7b56034bb9bfd27c5d4f3bc4692daca7","impliedFormat":1},{"version":"0cb167c371eaa8c869f8a7656a7296f2e4fae43b4d8b803a680236b24794e5f9","impliedFormat":1},{"version":"0a839dba0287cc0481ad4beedd48a1c64acf1e212ae865d1315f7007ca215161","impliedFormat":1},{"version":"38dc4655376cd1a4bd6bb3763d92949233e33d38d3dd3cbea7bbf218175a38ef","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"208a6a0bfb227bdf8fb964799d0a206c75cf03ccd72e63bf5495c9331354c6d6","affectsGlobalScope":true,"impliedFormat":1},{"version":"18fd40412d102c5564136f29735e5d1c3b455b8a37f920da79561f1fde068208","impliedFormat":1},{"version":"48a679952eefe4cb776d5a0e1ccba2d3eb53b57448bbb7abc1fcebcbd5440188","impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"2d14da6ecb49bf828d83948765ec2d3a579d476bbb9645e749610baa6ec880ca","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"5b7aa3c4c1a5d81b411e8cb302b45507fea9358d3569196b27eb1a27ae3a90ef","affectsGlobalScope":true,"impliedFormat":1},{"version":"5987a903da92c7462e0b35704ce7da94d7fdc4b89a984871c0e2b87a8aae9e69","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea08a0345023ade2b47fbff5a76d0d0ed8bff10bc9d22b83f40858a8e941501c","impliedFormat":1},{"version":"0aef708fb4c7a6b915e8305cbfac40cd207b032dbaabe9a01889a5fff3254681","impliedFormat":1},{"version":"ae062ce7d9510060c5d7e7952ae379224fb3f8f2dd74e88959878af2057c143b","impliedFormat":1},{"version":"ad9bdafb4e7abf14cc53ce7970486a84c87831e62891e5dfe798ddcd55e84701","affectsGlobalScope":true,"impliedFormat":1},{"version":"358765d5ea8afd285d4fd1532e78b88273f18cb3f87403a9b16fef61ac9fdcfe","impliedFormat":1},{"version":"71d3ae6a5e73ca4130762560425e00984ebaff64d5353a3333d1bb7eb86ef336","impliedFormat":1},{"version":"fc5ad2ec8b130a59612c5cfcc2cdca9bcdd80851adb8acdb004aaeff00cc978d","impliedFormat":99},{"version":"f76d2226b20ec0d3e884114cfbbb933cb4d25d26cc306e8315e21a0e3afafc39","impliedFormat":99},{"version":"709b4ace1214759ca558d2b85c1ac20011c384077769ed8e08d6b2e6d68ef6d9","impliedFormat":99},{"version":"638a22f8e16b31c37297483fa38cfc760ed309b16d4c606a7b4532f4394c9c0c","impliedFormat":99},{"version":"9394183f4c37b8591156f32e41223c9e0dd211eefe7499155d4cdf15268eaea8","impliedFormat":99},{"version":"2403907e4ef7f180350b25e0acb6ffa78c610047d159ecc6964b4229bfb40fb2","impliedFormat":99},{"version":"1504305fd16da58cd87b9ed49129a10b7fa354f414f539bdfbe403658cd6be8f","impliedFormat":99},{"version":"0824e917b53180b0b433f4fa85a1cc1d689a6c5f118bfdbe2086aa3b328c12db","impliedFormat":99},{"version":"7d3e062a778b8f5ea4f0cac7e925e31f88e6739812ebc5f827474324a4048f14","impliedFormat":99},{"version":"6fd401f9fc270efbde915a01325950b046bbfa4e041c4cde91566b3d178b05fc","impliedFormat":99},{"version":"9327db71e743ca5810396f33be57f88bae2b52d53c00aeb2668f41a6ef14c52a","impliedFormat":99},{"version":"e27361b231a041b6c4da9fae61463d39d99f64926e1cd732513fd413b77bf869","impliedFormat":99},{"version":"07cb5a2736a656d1d53b10f7717d5e816613741510a42dfc32f0f257406900e9","impliedFormat":99},{"version":"1f97a93af87366e65131932b6f5173b8eebfb7c3a61aa32626cc348b51c77b80","impliedFormat":99},{"version":"e0864480ea083087d705f9405bd6bf59b795e8474c3447f0d6413b2bce535a09","impliedFormat":99},{"version":"e67cbea16f1994af89efd700542dbf3828a46a52b29e4d67e801bd7869dc103c","impliedFormat":99},{"version":"d127908bdafc0f2d80a2ad9b17b6599d411545ced7773da2374e75d7992ccef2","impliedFormat":99},{"version":"f582b0fcbf1eea9b318ab92fb89ea9ab2ebb84f9b60af89328a91155e1afce72","impliedFormat":99},{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"38cb107048cd8ba54a70014ef9a30cf57bee0d9f10a0ca4cefa974056e1ee460","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"3cc8a3d123b6b232d48d34b51b785f9da8d193f5b5817fa521fcd2f3b9315c55","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"9a195d8476f48523446ece812e5450b5b1ec8c3b6abf99efb8ee2479524f6f56","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"354a7f8e1287d9d6b7561bc97fdd8cbc2f7c1dd79e4cb37b942e8a5cfaff1085","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"d8a62a1763683460cfc7c6d66214859909d3f91f34b6e9009aa9443b241f08c0","affectsGlobalScope":true,"impliedFormat":99},{"version":"f3fe8197217cb6fd6e07c0eaa0966e4ece77cd779cbfc55767a93704cc692e04","impliedFormat":1},{"version":"a18642ddf216f162052a16cba0944892c4c4c977d3306a87cb673d46abbb0cbf","impliedFormat":1},{"version":"617112682fcb55805f0a0e16450397edc016881962eee4298f4f4e3ef795aee8","impliedFormat":1},{"version":"4ec16d7a4e366c06a4573d299e15fe6207fc080f41beac5da06f4af33ea9761e","impliedFormat":99},{"version":"71fd5e58251bf9faae3c6c3413db7fe917abce3cbfb5390daac2d971dca8f68d","impliedFormat":99},{"version":"e50d04d261120f8f9d07e85698735d5b88f3a66bc395c08ce26c3d817d141a73","affectsGlobalScope":true,"impliedFormat":99},{"version":"8ecc9dd98b423db66eefccce5e7fcf5d6fb452d5673fcdbe751cf7977dd93b9f","impliedFormat":99},{"version":"6b50db6951aa5dd210715a37636f6f549ffc21556933ed6ff8a1653305a4c360","impliedFormat":99},{"version":"087049bb778b44ad4e720892e4bb22558e54bd6b51d5d746bd0e688eda35f4d8","impliedFormat":99},{"version":"751764bb94219b4ce8f5475dc35d3de2e432fea01a0c9610cd7f69ad05e398c6","impliedFormat":1},{"version":"58eb22ce690f7941a952f307d51a210195798c9fbc381470fe7e0adb228d4c92","impliedFormat":1},{"version":"f63cb353cd53da6be4a34f6fdece6316dac14fd62cccf9a4d2ce6bab2c37bc8c","impliedFormat":1},{"version":"cace2314ce18e7efedfcdaddb80e6428bb3d1b52a874eaa6bff779ba0c36de2b","impliedFormat":1},{"version":"3d922ac35e7bd201c09c71d0a3be9cab0ac41bdd0d5115f2734c8555629e5414","impliedFormat":1},{"version":"ae18a824baa6829b4b687f4e678d97c2b3f0ee75a82e2cff792180002f1e2a82","impliedFormat":1},{"version":"fe1baccba85e2af0fdaca57b32b34f3fd602609bb0b29aeb0609000dbcd75446","impliedFormat":1},{"version":"b7c5ef46a22a80ddac8f1e794c9b91c7082b515936e7dadb2c537ceb66b28ec5","impliedFormat":99},{"version":"1c7b696b935809569a770386fe99319147ed3d4d67ab9bfa1f35b632380b3bfb","signature":"f1a1b21a223c18a29308ebff0b002317e4bb8aa5e350164f8c8c3b8bde33a535"}],"root":[270],"options":{"allowSyntheticDefaultImports":true,"composite":true,"module":99},"referencedMap":[[215,1],[217,1],[218,2],[263,3],[266,1],[267,4],[268,5],[107,6],[105,1],[262,1],[160,7],[161,7],[162,8],[114,9],[163,10],[164,11],[165,12],[109,1],[112,13],[110,1],[111,1],[166,14],[167,15],[168,16],[169,17],[170,18],[171,19],[172,19],[173,20],[174,21],[175,22],[176,23],[115,1],[113,1],[177,24],[178,25],[179,26],[213,27],[180,28],[181,1],[182,29],[183,30],[184,31],[185,32],[186,33],[187,34],[188,35],[189,36],[190,37],[191,37],[192,38],[193,1],[194,39],[195,40],[197,41],[196,42],[198,43],[199,44],[200,45],[201,46],[202,47],[203,48],[204,49],[205,50],[206,51],[207,52],[208,53],[209,54],[210,55],[116,1],[117,1],[118,1],[156,56],[157,1],[158,1],[159,43],[211,57],[212,58],[261,59],[260,1],[97,60],[98,61],[96,1],[106,1],[102,62],[104,63],[103,64],[101,65],[100,1],[248,66],[246,67],[247,68],[235,69],[236,67],[243,70],[234,71],[239,72],[249,1],[240,73],[245,74],[251,75],[250,76],[233,77],[241,78],[242,79],[237,80],[244,66],[238,81],[264,1],[265,82],[230,83],[257,84],[220,85],[221,86],[224,87],[216,88],[223,89],[219,90],[214,1],[225,91],[226,92],[232,1],[84,1],[80,1],[81,1],[15,1],[13,1],[14,1],[19,1],[18,1],[2,1],[20,1],[21,1],[22,1],[23,1],[24,1],[25,1],[26,1],[27,1],[3,1],[28,1],[29,1],[4,1],[30,1],[34,1],[31,1],[32,1],[33,1],[35,1],[36,1],[37,1],[5,1],[38,1],[39,1],[40,1],[41,1],[6,1],[45,1],[42,1],[43,1],[44,1],[46,1],[7,1],[47,1],[52,1],[53,1],[48,1],[49,1],[50,1],[51,1],[8,1],[57,1],[54,1],[55,1],[56,1],[58,1],[9,1],[59,1],[60,1],[61,1],[63,1],[62,1],[64,1],[65,1],[10,1],[66,1],[67,1],[68,1],[11,1],[69,1],[70,1],[71,1],[72,1],[73,1],[74,1],[12,1],[75,1],[82,1],[76,1],[77,1],[78,1],[79,1],[1,1],[17,1],[16,1],[134,93],[144,94],[133,93],[154,95],[125,96],[124,97],[153,98],[147,99],[152,100],[127,101],[141,102],[126,103],[150,104],[122,105],[121,98],[151,106],[123,107],[128,108],[129,1],[132,108],[119,1],[155,109],[145,110],[136,111],[137,112],[139,113],[135,114],[138,115],[148,98],[130,116],[131,117],[140,118],[120,119],[143,110],[142,108],[146,1],[149,120],[269,121],[87,122],[259,123],[90,124],[253,1],[255,125],[254,1],[88,122],[86,1],[89,126],[258,1],[252,1],[227,1],[228,1],[256,127],[222,128],[231,1],[229,129],[94,130],[85,131],[92,132],[99,133],[91,134],[95,135],[93,136],[108,137],[83,1],[270,138]],"semanticDiagnosticsPerFile":[[266,[{"start":43,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}},{"start":90,"length":17,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/template'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/template/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/template","mode":99}}]}}]],[267,[{"start":26,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}}]],[268,[{"start":71,"length":6,"messageText":"Cannot find module 'svgo' or its corresponding type declarations.","category":1,"code":2307},{"start":171,"length":13,"code":7016,"category":1,"messageText":{"messageText":"Could not find a declaration file for module '@babel/core'. '/Users/pavel/Desktop/DocsGPT-main/frontend/node_modules/@babel/core/lib/index.js' implicitly has an 'any' type.","category":1,"code":7016,"next":[{"info":{"moduleReference":"@babel/core","mode":99}}]}}]]],"latestChangedDtsFile":"./vite.config.d.ts","version":"6.0.3"}
\ No newline at end of file
diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo
deleted file mode 100644
index bf201af2..00000000
--- a/frontend/tsconfig.tsbuildinfo
+++ /dev/null
@@ -1 +0,0 @@
-{"root":["./src/app.tsx","./src/hero.tsx","./src/navigation.tsx","./src/pagenotfound.tsx","./src/env.ts","./src/main.tsx","./src/store.ts","./src/vite-env.d.ts","./src/admin/activity.tsx","./src/admin/adminui.test.ts","./src/admin/adminui.tsx","./src/admin/admins.tsx","./src/admin/loaderror.test.tsx","./src/admin/overview.tsx","./src/admin/quotaeditor.tsx","./src/admin/quotas.tsx","./src/admin/usage.tsx","./src/admin/usagechart.test.ts","./src/admin/usagechart.tsx","./src/admin/userquotamodal.tsx","./src/admin/userusagemodal.tsx","./src/admin/users.tsx","./src/admin/index.tsx","./src/admin/quotautils.test.ts","./src/admin/quotautils.ts","./src/admin/usagechartdata.test.ts","./src/admin/usagechartdata.ts","./src/agents/agentcard.test.tsx","./src/agents/agentcard.tsx","./src/agents/agentlogs.test.tsx","./src/agents/agentlogs.tsx","./src/agents/agentpageheader.test.tsx","./src/agents/agentpageheader.tsx","./src/agents/agentpreview.tsx","./src/agents/agentrouteguard.test.tsx","./src/agents/agentrouteguard.tsx","./src/agents/agentslist.tsx","./src/agents/foldercard.tsx","./src/agents/newagent.test.tsx","./src/agents/newagent.tsx","./src/agents/sharedagent.tsx","./src/agents/sharedagentcard.test.tsx","./src/agents/sharedagentcard.tsx","./src/agents/sharedagentgate.tsx","./src/agents/agentaccess.test.ts","./src/agents/agentaccess.ts","./src/agents/agentpreviewslice.ts","./src/agents/agents.config.ts","./src/agents/index.tsx","./src/agents/paths.test.ts","./src/agents/paths.ts","./src/agents/components/agentpagetoolbar.test.tsx","./src/agents/components/agentpagetoolbar.tsx","./src/agents/components/agentpreviewsheet.test.tsx","./src/agents/components/agentpreviewsheet.tsx","./src/agents/components/agenttypemodal.test.tsx","./src/agents/components/agenttypemodal.tsx","./src/agents/components/guardrailevents.test.tsx","./src/agents/components/guardrailevents.tsx","./src/agents/components/guardrailssection.test.tsx","./src/agents/components/guardrailssection.tsx","./src/agents/components/sponsoredresourcesnotice.test.tsx","./src/agents/components/sponsoredresourcesnotice.tsx","./src/agents/hooks/useagentsearch.ts","./src/agents/hooks/useagentsfetch.ts","./src/agents/schedules/rundetaildrawer.test.tsx","./src/agents/schedules/rundetaildrawer.tsx","./src/agents/schedules/runlog.test.tsx","./src/agents/schedules/runlog.tsx","./src/agents/schedules/scheduleformmodal.tsx","./src/agents/schedules/schedulerow.test.tsx","./src/agents/schedules/schedulerow.tsx","./src/agents/schedules/schedulertoolcallcard.test.ts","./src/agents/schedules/schedulertoolcallcard.tsx","./src/agents/schedules/schedulesview.test.tsx","./src/agents/schedules/schedulesview.tsx","./src/agents/schedules/statusbadge.test.tsx","./src/agents/schedules/statusbadge.tsx","./src/agents/schedules/timezonecombobox.test.ts","./src/agents/schedules/timezonecombobox.tsx","./src/agents/schedules/cronbuilder.test.ts","./src/agents/schedules/cronbuilder.ts","./src/agents/schedules/schedulesslice.test.ts","./src/agents/schedules/schedulesslice.ts","./src/agents/types/index.ts","./src/agents/types/schedule.ts","./src/agents/types/workflow.ts","./src/agents/workflow/canvascontrols.tsx","./src/agents/workflow/nodepalette.test.tsx","./src/agents/workflow/nodepalette.tsx","./src/agents/workflow/workflowbuilder.tsx","./src/agents/workflow/workflowminimap.test.tsx","./src/agents/workflow/workflowmodelscontext.ts","./src/agents/workflow/workflowpreview.test.tsx","./src/agents/workflow/workflowpreview.tsx","./src/agents/workflow/workflowrunartifacts.test.tsx","./src/agents/workflow/workflowrunartifacts.tsx","./src/agents/workflow/codenodeconfig.test.ts","./src/agents/workflow/codenodeconfig.ts","./src/agents/workflow/documentconfig.test.ts","./src/agents/workflow/documentconfig.ts","./src/agents/workflow/nodetones.test.ts","./src/agents/workflow/nodetones.ts","./src/agents/workflow/simplecel.test.ts","./src/agents/workflow/simplecel.ts","./src/agents/workflow/workflowhelpers.test.ts","./src/agents/workflow/workflowhelpers.ts","./src/agents/workflow/workflowpreviewslice.test.ts","./src/agents/workflow/workflowpreviewslice.ts","./src/agents/workflow/components/mobileblocker.tsx","./src/agents/workflow/components/nodedocumentscontrol.tsx","./src/agents/workflow/components/prompttextarea.test.tsx","./src/agents/workflow/components/prompttextarea.tsx","./src/agents/workflow/components/workflowdetailssheet.test.tsx","./src/agents/workflow/components/workflowdetailssheet.tsx","./src/agents/workflow/hooks/useundoredo.ts","./src/agents/workflow/nodes/basenode.tsx","./src/agents/workflow/nodes/codenode.tsx","./src/agents/workflow/nodes/conditionnode.tsx","./src/agents/workflow/nodes/outputvariableline.tsx","./src/agents/workflow/nodes/setstatenode.tsx","./src/agents/workflow/nodes/index.tsx","./src/agents/workflow/panels/agentpanel.test.tsx","./src/agents/workflow/panels/agentpanel.tsx","./src/agents/workflow/panels/codepanel.tsx","./src/agents/workflow/panels/conditionpanel.test.tsx","./src/agents/workflow/panels/conditionpanel.tsx","./src/agents/workflow/panels/nodepanel.test.tsx","./src/agents/workflow/panels/nodepanel.tsx","./src/agents/workflow/panels/notepanel.tsx","./src/agents/workflow/panels/statepanel.test.tsx","./src/agents/workflow/panels/statepanel.tsx","./src/agents/workflow/panels/types.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/throttle.ts","./src/api/services/adminservice.ts","./src/api/services/conversationservice.ts","./src/api/services/custommodelsservice.ts","./src/api/services/devicesservice.ts","./src/api/services/modelservice.ts","./src/api/services/patservice.test.ts","./src/api/services/patservice.ts","./src/api/services/schedulesservice.test.ts","./src/api/services/schedulesservice.ts","./src/api/services/teamsservice.test.ts","./src/api/services/teamsservice.ts","./src/api/services/userservice.ts","./src/components/actionbuttons.tsx","./src/components/adminroute.test.tsx","./src/components/adminroute.tsx","./src/components/artifactsidebar.test.tsx","./src/components/artifactsidebar.tsx","./src/components/chunks.test.tsx","./src/components/chunks.tsx","./src/components/configfields.test.tsx","./src/components/configfields.tsx","./src/components/connectorauth.test.tsx","./src/components/connectorauth.tsx","./src/components/connectortree.test.tsx","./src/components/connectortree.tsx","./src/components/copybutton.test.tsx","./src/components/copybutton.tsx","./src/components/documentartifactview.tsx","./src/components/errorboundary.test.tsx","./src/components/errorboundary.tsx","./src/components/filepicker.test.tsx","./src/components/filepicker.tsx","./src/components/filetree.tsx","./src/components/fileupload.test.tsx","./src/components/fileupload.tsx","./src/components/googledrivepicker.tsx","./src/components/graphview.tsx","./src/components/head.tsx","./src/components/help.tsx","./src/components/markdownpreview.tsx","./src/components/mermaidrenderer.test.tsx","./src/components/mermaidrenderer.tsx","./src/components/messageinput.test.tsx","./src/components/messageinput.tsx","./src/components/multiselectpopover.test.tsx","./src/components/multiselectpopover.tsx","./src/components/notification.tsx","./src/components/pagetoolbar.test.tsx","./src/components/pagetoolbar.tsx","./src/components/profilebutton.tsx","./src/components/rolebadge.test.tsx","./src/components/rolebadge.tsx","./src/components/searchinput.test.tsx","./src/components/searchinput.tsx","./src/components/skeletonloader.test.tsx","./src/components/skeletonloader.tsx","./src/components/sourcemarkdown.test.tsx","./src/components/sourcemarkdown.tsx","./src/components/sourcespopoverfooter.test.tsx","./src/components/sourcespopoverfooter.tsx","./src/components/statcard.test.tsx","./src/components/statcard.tsx","./src/components/texttospeechbutton.tsx","./src/components/toolicon.tsx","./src/components/uploadtoast.test.tsx","./src/components/uploadtoast.tsx","./src/components/viewonlynotice.test.tsx","./src/components/viewonlynotice.tsx","./src/components/wikiviewer.test.tsx","./src/components/wikiviewer.tsx","./src/components/artifactviewutils.test.ts","./src/components/artifactviewutils.ts","./src/components/chunkutils.test.ts","./src/components/chunkutils.ts","./src/components/graphviewutils.test.ts","./src/components/graphviewutils.ts","./src/components/mermaidrenderqueue.test.ts","./src/components/mermaidsecurity.test.ts","./src/components/mermaidsecurity.ts","./src/components/useartifactbytes.ts","./src/components/wikiviewerutils.test.ts","./src/components/wikiviewerutils.ts","./src/components/graph/graphcanvascontrols.tsx","./src/components/graph/graphchunksheet.test.tsx","./src/components/graph/graphchunksheet.tsx","./src/components/graph/graphentities.tsx","./src/components/graph/graphentitysearch.tsx","./src/components/graph/graphnodepanel.test.tsx","./src/components/graph/graphnodepanel.tsx","./src/components/graph/graphsourceview.test.tsx","./src/components/graph/graphsourceview.tsx","./src/components/graph/graphtypedot.tsx","./src/components/graph/graphcanvasutils.test.ts","./src/components/graph/graphcanvasutils.ts","./src/components/graph/usegraphnodedetail.ts","./src/components/message-input/attachfilebutton.tsx","./src/components/message-input/attachmentchiplist.test.tsx","./src/components/message-input/attachmentchiplist.tsx","./src/components/message-input/micbutton.tsx","./src/components/message-input/sourcestrigger.tsx","./src/components/message-input/toolstrigger.tsx","./src/components/message-input/armedsend.test.tsx","./src/components/message-input/armedsend.ts","./src/components/message-input/attachmentreadability.test.ts","./src/components/message-input/attachmentreadability.ts","./src/components/message-input/composercontrols.test.tsx","./src/components/message-input/index.ts","./src/components/message-input/uploadstallguard.test.ts","./src/components/message-input/uploadstallguard.ts","./src/components/tree/pathheader.test.tsx","./src/components/tree/pathheader.tsx","./src/components/tree/readerpanel.tsx","./src/components/tree/sourceeditsheet.test.tsx","./src/components/tree/sourceeditsheet.tsx","./src/components/tree/sourcenavigator.test.tsx","./src/components/tree/sourcenavigator.tsx","./src/components/tree/treebrowser.test.tsx","./src/components/tree/treebrowser.tsx","./src/components/tree/navigatorutils.test.ts","./src/components/tree/navigatorutils.ts","./src/components/tree/types.ts","./src/components/tree/usereingestwait.ts","./src/components/types/index.ts","./src/components/ui/accordion.test.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert.test.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.test.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.test.tsx","./src/components/ui/badge.tsx","./src/components/ui/bar-tint-reset.ts","./src/components/ui/breadcrumb.test.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.test.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.test.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.test.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.test.tsx","./src/components/ui/command.tsx","./src/components/ui/description-list.test.tsx","./src/components/ui/description-list.tsx","./src/components/ui/dialog.test.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.test.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/dropzone.test.tsx","./src/components/ui/dropzone.tsx","./src/components/ui/empty-state.test.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/form-field.test.tsx","./src/components/ui/form-field.tsx","./src/components/ui/icon-button.test.tsx","./src/components/ui/icon-button.tsx","./src/components/ui/input.test.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/list-row.test.tsx","./src/components/ui/list-row.tsx","./src/components/ui/loading-state.test.tsx","./src/components/ui/loading-state.tsx","./src/components/ui/message-scroller.tsx","./src/components/ui/modal.test.tsx","./src/components/ui/modal.tsx","./src/components/ui/multi-select.test.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/option-card.test.tsx","./src/components/ui/option-card.tsx","./src/components/ui/overlay-chrome.test.tsx","./src/components/ui/pagination-size.test.tsx","./src/components/ui/pagination.test.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.test.tsx","./src/components/ui/progress.tsx","./src/components/ui/section-header.test.tsx","./src/components/ui/section-header.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.test.tsx","./src/components/ui/separator.tsx","./src/components/ui/setting-row.test.tsx","./src/components/ui/setting-row.tsx","./src/components/ui/sheet.test.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.test.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/spinner.test.tsx","./src/components/ui/spinner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.test.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.test.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.test.tsx","./src/components/ui/textarea.tsx","./src/components/ui/time-picker.test.tsx","./src/components/ui/time-picker.tsx","./src/components/ui/toast.test.tsx","./src/components/ui/toast.tsx","./src/components/ui/toggle-group.test.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/tooltip.test.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/use-focus-return.ts","./src/constants/fileupload.test.ts","./src/constants/fileupload.ts","./src/conversation/answerflow.test.tsx","./src/conversation/answerflow.tsx","./src/conversation/conversation.tsx","./src/conversation/conversationbubble.test.tsx","./src/conversation/conversationbubble.tsx","./src/conversation/conversationmessages.test.tsx","./src/conversation/conversationmessages.tsx","./src/conversation/conversationtile.test.tsx","./src/conversation/conversationtile.tsx","./src/conversation/markdownanswer.codespans.test.tsx","./src/conversation/markdownanswer.math.test.tsx","./src/conversation/markdownanswer.sandbox.test.tsx","./src/conversation/markdownanswer.tsx","./src/conversation/researchprogress.tsx","./src/conversation/sharedconversation.tsx","./src/conversation/streamingstatusline.tsx","./src/conversation/wikiwritetoolcallcard.test.tsx","./src/conversation/answerflowintegration.test.tsx","./src/conversation/answersegments.test.ts","./src/conversation/answersegments.ts","./src/conversation/artifactchips.test.ts","./src/conversation/artifactchips.ts","./src/conversation/conversationhandlers.ts","./src/conversation/conversationlistener.test.ts","./src/conversation/conversationmodels.ts","./src/conversation/conversationslice.test.ts","./src/conversation/conversationslice.ts","./src/conversation/quotaerror.test.ts","./src/conversation/quotaerror.ts","./src/conversation/sandboxlinks.live.test.ts","./src/conversation/sandboxlinks.test.ts","./src/conversation/sandboxlinks.ts","./src/conversation/sharedconversationslice.ts","./src/conversation/wikitoolcall.test.ts","./src/conversation/wikitoolcall.ts","./src/conversation/markdown/answermarkdown.ts","./src/conversation/markdown/micromarkextension.ts","./src/conversation/markdown/remarkcitations.ts","./src/conversation/markdown/remarkdisplaymath.ts","./src/conversation/markdown/singledollarmath.ts","./src/conversation/markdown/texmath.ts","./src/conversation/types/index.ts","./src/design/designsystem.tsx","./src/design/cardsurfaces.lint.test.ts","./src/design/designrules.lint.test.ts","./src/events/eventstreamprovider.tsx","./src/events/dispatchevent.test.ts","./src/events/dispatchevent.ts","./src/events/eventstreamclient.ts","./src/events/useeventstream.ts","./src/hooks/fetchmeroles.test.ts","./src/hooks/index.ts","./src/hooks/usedarktheme.test.tsx","./src/hooks/usedatainitializer.ts","./src/hooks/usemediaquery.test.tsx","./src/hooks/usetokenauth.ts","./src/lib/markdown.test.tsx","./src/lib/markdown.tsx","./src/lib/utils.test.ts","./src/lib/utils.ts","./src/locale/accesstokens.test.ts","./src/locale/i18n.ts","./src/locale/logstrace.test.ts","./src/modals/accesstokencreatedmodal.tsx","./src/modals/addactionmodal.tsx","./src/modals/addtoolmodal.tsx","./src/modals/agentdetailsmodal.test.tsx","./src/modals/agentdetailsmodal.tsx","./src/modals/configtoolmodal.tsx","./src/modals/confirmationmodal.tsx","./src/modals/createaccesstokenmodal.tsx","./src/modals/custommodelmodal.test.tsx","./src/modals/custommodelmodal.tsx","./src/modals/foldermanagementmodal.tsx","./src/modals/importagentmodal.test.tsx","./src/modals/importagentmodal.tsx","./src/modals/importspecmodal.tsx","./src/modals/jwtmodal.tsx","./src/modals/mcpservermodal.test.tsx","./src/modals/mcpservermodal.tsx","./src/modals/movetofoldermodal.test.tsx","./src/modals/movetofoldermodal.tsx","./src/modals/regenerateaccesstokenmodal.tsx","./src/modals/searchconversationsmodal.test.tsx","./src/modals/searchconversationsmodal.tsx","./src/modals/shareconversationmodal.tsx","./src/modals/types/index.ts","./src/models/misc.ts","./src/models/types.ts","./src/navigation/detailbreadcrumb.tsx","./src/navigation/mobiletopbar.test.tsx","./src/navigation/mobiletopbar.tsx","./src/navigation/sectionindexpage.tsx","./src/navigation/sectionnav.tsx","./src/navigation/sectionpageheader.tsx","./src/navigation/sectionpills.tsx","./src/navigation/sectionrail.tsx","./src/navigation/sectionshell.test.tsx","./src/navigation/sectionshell.tsx","./src/navigation/sidebarlevel.tsx","./src/navigation/sidebarlevelprovider.tsx","./src/navigation/sidebarnavrow.test.tsx","./src/navigation/sections.test.ts","./src/navigation/sections.ts","./src/navigation/uselastapppath.ts","./src/navigation/usesectioncontext.ts","./src/navigation/usesectionresolver.ts","./src/notifications/actiontoast.test.tsx","./src/notifications/actiontoast.tsx","./src/notifications/teamnotificationtoast.test.tsx","./src/notifications/teamnotificationtoast.tsx","./src/notifications/toolapprovaltoast.test.tsx","./src/notifications/toolapprovaltoast.tsx","./src/notifications/actiontoastslice.test.ts","./src/notifications/actiontoastslice.ts","./src/notifications/dismissedpersistence.test.ts","./src/notifications/dismissedpersistence.ts","./src/notifications/notificationsslice.test.ts","./src/notifications/notificationsslice.ts","./src/preferences/promptsmodal.test.tsx","./src/preferences/promptsmodal.tsx","./src/preferences/preferenceapi.ts","./src/preferences/preferenceslice.test.ts","./src/preferences/preferenceslice.ts","./src/preferences/types/index.ts","./src/settings/analytics.tsx","./src/settings/converttowikimodal.tsx","./src/settings/custommodels.tsx","./src/settings/enablegraphragmodal.tsx","./src/settings/general.tsx","./src/settings/logs.tsx","./src/settings/pairdevicemodal.tsx","./src/settings/personalaccesstokens.tsx","./src/settings/prompts.test.tsx","./src/settings/prompts.tsx","./src/settings/remotedeviceconfig.test.tsx","./src/settings/remotedeviceconfig.tsx","./src/settings/sourceconfigmodal.test.tsx","./src/settings/sourceconfigmodal.tsx","./src/settings/sources.test.tsx","./src/settings/sources.tsx","./src/settings/teams.test.tsx","./src/settings/teams.tsx","./src/settings/testretrievalmodal.notices.test.tsx","./src/settings/testretrievalmodal.test.tsx","./src/settings/testretrievalmodal.tsx","./src/settings/toolconfig.test.tsx","./src/settings/toolconfig.tsx","./src/settings/tools.test.tsx","./src/settings/tools.tsx","./src/settings/accesstokenutils.test.ts","./src/settings/accesstokenutils.ts","./src/settings/foldseries.test.ts","./src/settings/foldseries.ts","./src/settings/graphbuildslice.test.ts","./src/settings/graphbuildslice.ts","./src/settings/graphragenableutils.test.ts","./src/settings/graphragenableutils.ts","./src/settings/index.test.tsx","./src/settings/index.tsx","./src/settings/wikiconvertutils.test.ts","./src/settings/wikiconvertutils.ts","./src/settings/components/retrievaloptions.test.tsx","./src/settings/components/retrievaloptions.tsx","./src/settings/components/usagequota.tsx","./src/settings/traces/tracechips.tsx","./src/settings/traces/tracesheet.test.tsx","./src/settings/traces/tracesheet.tsx","./src/settings/traces/tracespandetails.tsx","./src/settings/traces/tracewaterfall.tsx","./src/settings/traces/traceutils.test.ts","./src/settings/traces/traceutils.ts","./src/settings/types/index.ts","./src/teams/sharetoteammodal.test.tsx","./src/teams/sharetoteammodal.tsx","./src/teams/teamswitcher.tsx","./src/teams/accesssettings.ts","./src/teams/teamsslice.test.ts","./src/teams/teamsslice.ts","./src/upload/upload.test.tsx","./src/upload/upload.tsx","./src/upload/uploadslice.test.ts","./src/upload/uploadslice.ts","./src/upload/types/ingestor.test.ts","./src/upload/types/ingestor.ts","./src/utils/accessutils.test.ts","./src/utils/accessutils.ts","./src/utils/browserutils.ts","./src/utils/chartutils.test.ts","./src/utils/chartutils.ts","./src/utils/connectorauthutils.test.ts","./src/utils/connectorauthutils.ts","./src/utils/datetimeutils.test.ts","./src/utils/datetimeutils.ts","./src/utils/httpmethodcolors.test.ts","./src/utils/httpmethodcolors.ts","./src/utils/idempotency.ts","./src/utils/jwtutils.test.ts","./src/utils/jwtutils.ts","./src/utils/objectutils.ts","./src/utils/providerutils.ts","./src/utils/sourceutils.test.ts","./src/utils/sourceutils.ts","./src/utils/streamingstatusutils.test.ts","./src/utils/streamingstatusutils.ts","./src/utils/stringutils.ts","./src/utils/toolutils.test.ts","./src/utils/toolutils.ts"],"version":"6.0.3"}
\ No newline at end of file
diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts
deleted file mode 100644
index 089eeef9..00000000
--- a/frontend/vite.config.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-declare const _default: import("vite").UserConfigFnObject;
-export default _default;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
deleted file mode 100644
index 905b0af3..00000000
--- a/frontend/vite.config.js
+++ /dev/null
@@ -1,41 +0,0 @@
-///
-import { defineConfig, loadEnv } from 'vite';
-import react from '@vitejs/plugin-react';
-import svgr from 'vite-plugin-svgr';
-import path from 'path';
-// https://vitejs.dev/config/
-export default defineConfig(({ mode }) => {
- const env = loadEnv(mode, process.cwd(), '');
- return {
- plugins: [react(), svgr()],
- resolve: {
- alias: {
- '@': path.resolve(import.meta.dirname, './src'),
- },
- // Radix keeps its body pointer-events lock in module scope. Any second copy
- // npm nests, now or after a future bump, can leave that lock stuck on .
- dedupe: ['@radix-ui/react-dismissable-layer'],
- },
- server: {
- // Extra dev hosts (e.g. a tailscale name) come from VITE_ALLOWED_HOSTS in
- // an untracked .env.local; machine-specific names stay out of the repo.
- allowedHosts: env.VITE_ALLOWED_HOSTS
- ? env.VITE_ALLOWED_HOSTS.split(',')
- .map((h) => h.trim())
- .filter(Boolean)
- : [],
- // Use polling for file watching when running inside Docker.
- // Native fs events do not propagate from Windows hosts into Linux
- // containers, so Chokidar falls back to polling which works reliably.
- watch: env.DOCKER
- ? { usePolling: true, interval: 300 }
- : undefined,
- },
- test: {
- environment: 'happy-dom',
- globals: true,
- include: ['src/**/*.test.{ts,tsx}'],
- setupFiles: ['./vitest.setup.ts'],
- },
- };
-});
diff --git a/tests/api/user/agents/test_roles_access.py b/tests/api/user/agents/test_roles_access.py
index 80ddb1aa..022e7130 100644
--- a/tests/api/user/agents/test_roles_access.py
+++ b/tests/api/user/agents/test_roles_access.py
@@ -246,14 +246,22 @@ class TestUpdateAgent:
{"name": "n", "folder_id": str(folder["id"])})
assert _status(resp) == 200
- def test_workflow_validated_against_owner(self, app, pg_conn):
+ def test_workflow_validated_against_owner_and_owner_only_to_change(self, app, pg_conn):
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
agent_id = _agent(pg_conn, agent_type="workflow")
+ # An editor can't point the agent at another of the owner's workflows.
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
+ assert _status(resp) == 403
+ assert _row(pg_conn, agent_id)["workflow_id"] is None
+
+ resp = self._put(app, pg_conn, agent_id, OWNER, {"workflow": str(wf["id"])})
assert _status(resp) == 200
assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
+ # Re-sending the current one is a plain save.
+ resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
+ assert _status(resp) == 200
mine = WorkflowsRepository(pg_conn).create(EDITOR, "editor-wf")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(mine["id"])})
@@ -269,10 +277,14 @@ class TestUpdateAgent:
_team_share(pg_conn, "tool", shared_tool, owner=STRANGER)
agent_id = _agent(pg_conn)
- ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
- assert _status(ok) == 200
+ # The owner owning a tool isn't enough: it must reach the editor.
+ denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool]})
+ assert _status(denied) == 403
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
assert _status(denied) == 403
+ assert _status(self._put(app, pg_conn, agent_id, OWNER, {"tools": [owner_tool]})) == 200
+ ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
+ assert _status(ok) == 200
set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
# Already attached: keeping it is fine.
diff --git a/tests/api/user/test_attach_owner_refs.py b/tests/api/user/test_attach_owner_refs.py
new file mode 100644
index 00000000..dad9276a
--- /dev/null
+++ b/tests/api/user/test_attach_owner_refs.py
@@ -0,0 +1,212 @@
+"""What a team editor may newly reference from the owner's agent or workflow.
+
+An agent (and its workflow) runs as its owner, so owning a resource is not
+enough for an editor to wire it in: the editor must be able to use it
+themselves. Otherwise an editor of one shared agent could attach the owner's
+private tool (run with the owner's credentials), source or prompt, or the
+workflow of another of the owner's agents and then edit that graph.
+
+Also covers id casing: an uppercase UUID must resolve the same switches as
+the canonical lowercase one. Uses real repositories on ``pg_conn``.
+"""
+
+from __future__ import annotations
+
+import uuid
+
+import pytest
+
+from docsgpt.api.user.resource_access import resolve, set_settings
+from docsgpt.storage.db.repositories.agents import AgentsRepository
+from docsgpt.storage.db.repositories.prompts import PromptsRepository
+from docsgpt.storage.db.repositories.sources import SourcesRepository
+from docsgpt.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+)
+from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
+from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
+
+from tests.api.user.test_resource_sponsors import (
+ EDITOR,
+ OWNER,
+ _agent,
+ _call,
+ _put,
+ _row,
+ _status,
+ _wf_body,
+)
+
+
+@pytest.fixture
+def app():
+ from flask import Flask
+
+ return Flask(__name__)
+
+
+def _owner_private(conn):
+ """A tool, prompt and source the owner never shared."""
+ tool = str(UserToolsRepository(conn).create(OWNER, "api_tool")["id"])
+ prompt = str(PromptsRepository(conn).create(OWNER, "private", "Owner prompt")["id"])
+ source = str(SourcesRepository(conn).create("owner-src", user_id=OWNER)["id"])
+ return tool, prompt, source
+
+
+def _share_tool_with_editor(conn, team_id, tool, level="viewer"):
+ TeamResourceGrantsRepository(conn).grant(
+ team_id, "tool", tool, OWNER, OWNER, access_level=level, target_user_id=EDITOR
+ )
+
+
+class TestAgentAttach:
+ def test_editor_cannot_attach_owner_private_tool(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _owner_private(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
+ assert _status(resp) == 403
+ assert _row(pg_conn, agent_id)["tools"] in (None, [])
+
+ def test_editor_cannot_attach_owner_private_source(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ _, _, source = _owner_private(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"sources": [source]})
+ assert _status(resp) == 403
+
+ def test_editor_cannot_attach_owner_private_prompt(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ _, prompt, _ = _owner_private(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"prompt_id": prompt})
+ assert _status(resp) == 403
+
+ def test_editor_may_attach_owner_tool_shared_with_them(self, app, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool, _, _ = _owner_private(pg_conn)
+ _share_tool_with_editor(pg_conn, team_id, tool)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
+ assert _status(resp) == 200, resp.get_json()
+ row = _row(pg_conn, agent_id)
+ assert [str(t) for t in row["tools"]] == [tool]
+ # The owner owns it, so it runs as the owner: no sponsor.
+ assert not row.get("resource_sponsors")
+
+ def test_editor_keeps_owner_refs_already_on_agent(self, app, pg_conn):
+ tool, prompt, source = _owner_private(pg_conn)
+ agent_id, _ = _agent(pg_conn, tools=[tool], prompt_id=prompt, source_id=source)
+ resp = _put(
+ app, pg_conn, agent_id, EDITOR,
+ {"tools": [tool], "prompt_id": prompt, "source": source},
+ )
+ assert _status(resp) == 200, resp.get_json()
+
+ def test_owner_may_attach_own_private_tool(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, _, _ = _owner_private(pg_conn)
+ assert _status(_put(app, pg_conn, agent_id, OWNER, {"tools": [tool]})) == 200
+
+
+class TestAgentWorkflowSwap:
+ def _two_workflow_agents(self, pg_conn):
+ """Agent A (shared with EDITOR) and the owner's private agent B, each with a workflow."""
+ wf_a = str(WorkflowsRepository(pg_conn).create(OWNER, "A")["id"])
+ wf_b = str(WorkflowsRepository(pg_conn).create(OWNER, "B")["id"])
+ agent_a, _ = _agent(pg_conn, agent_type="workflow", workflow_id=wf_a)
+ AgentsRepository(pg_conn).create(
+ OWNER, "Private B", "published", description="d", key=f"k-{uuid.uuid4().hex}",
+ agent_type="workflow", workflow_id=wf_b,
+ )
+ return agent_a, wf_a, wf_b
+
+ def test_editor_cannot_swap_in_another_owner_workflow(self, app, pg_conn):
+ agent_a, wf_a, wf_b = self._two_workflow_agents(pg_conn)
+ resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_b})
+ assert _status(resp) == 403
+ assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_a
+
+ def test_editor_may_resend_current_workflow(self, app, pg_conn):
+ agent_a, wf_a, _ = self._two_workflow_agents(pg_conn)
+ resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_a})
+ assert _status(resp) == 200, resp.get_json()
+
+ def test_owner_may_swap_workflow(self, app, pg_conn):
+ agent_a, _, wf_b = self._two_workflow_agents(pg_conn)
+ assert _status(_put(app, pg_conn, agent_a, OWNER, {"workflow": wf_b})) == 200
+ assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_b
+
+
+class TestWorkflowNodeAttach:
+ def _setup(self, pg_conn):
+ wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
+ _, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
+ return str(wf["id"]), team_id
+
+ def _put_wf(self, app, pg_conn, wid, user, body):
+ from docsgpt.api.user.workflows.routes import WorkflowDetail
+
+ return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user,
+ json=body, args=(wid,))
+
+ def test_editor_cannot_add_owner_private_tool_to_node(self, app, pg_conn):
+ wid, _ = self._setup(pg_conn)
+ tool, _, _ = _owner_private(pg_conn)
+ assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))) == 403
+
+ def test_editor_cannot_add_owner_private_source_to_node(self, app, pg_conn):
+ wid, _ = self._setup(pg_conn)
+ _, _, source = _owner_private(pg_conn)
+ assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(source=source))) == 403
+
+ def test_editor_keeps_owner_node_refs_already_in_graph(self, app, pg_conn):
+ wid, _ = self._setup(pg_conn)
+ tool, _, source = _owner_private(pg_conn)
+ body = _wf_body(tool=tool, source=source)
+ assert _status(self._put_wf(app, pg_conn, wid, OWNER, body)) == 200
+ resp = self._put_wf(app, pg_conn, wid, EDITOR, body)
+ assert _status(resp) == 200, resp.get_json()
+
+ def test_editor_may_add_tool_shared_with_them(self, app, pg_conn):
+ wid, team_id = self._setup(pg_conn)
+ tool, _, _ = _owner_private(pg_conn)
+ _share_tool_with_editor(pg_conn, team_id, tool)
+ resp = self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))
+ assert _status(resp) == 200, resp.get_json()
+
+
+class TestUppercaseIds:
+ def _tool_not_usable_in_own(self, pg_conn, team_id):
+ """An owner tool the EDITOR only views, with ``viewers_can_use_in_agents`` off."""
+ tool, _, _ = _owner_private(pg_conn)
+ _share_tool_with_editor(pg_conn, team_id, tool)
+ set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OWNER)
+ return tool
+
+ def test_resolve_applies_switches_to_uppercase_id(self, pg_conn):
+ _, team_id = _agent(pg_conn)
+ tool = self._tool_not_usable_in_own(pg_conn, team_id)
+ lower = resolve(pg_conn, "tool", tool, EDITOR)
+ upper = resolve(pg_conn, "tool", tool.upper(), EDITOR)
+ assert lower is not None and upper is not None
+ assert not upper.can("use_in_own")
+ assert upper.settings == lower.settings
+ assert upper.resource_id == tool
+
+ def test_uppercase_tool_id_does_not_bypass_switch_on_attach(self, app, pg_conn):
+ agent_id, team_id = _agent(pg_conn)
+ tool = self._tool_not_usable_in_own(pg_conn, team_id)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool.upper()]})
+ assert _status(resp) == 403
+
+ def test_attached_ids_are_stored_canonical(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ tool, prompt, source = _owner_private(pg_conn)
+ resp = _put(
+ app, pg_conn, agent_id, OWNER,
+ {"tools": [tool.upper()], "prompt_id": prompt.upper(), "sources": [source.upper()]},
+ )
+ assert _status(resp) == 200, resp.get_json()
+ row = _row(pg_conn, agent_id)
+ assert [str(t) for t in row["tools"]] == [tool]
+ assert str(row["prompt_id"]) == prompt
+ assert [str(s) for s in row["extra_source_ids"] or []] + (
+ [str(row["source_id"])] if row.get("source_id") else []
+ ) == [source]
diff --git a/tests/api/user/test_tools_access.py b/tests/api/user/test_tools_access.py
index 07d6f3c3..b404f0e0 100644
--- a/tests/api/user/test_tools_access.py
+++ b/tests/api/user/test_tools_access.py
@@ -503,17 +503,44 @@ class TestMCPSaveAccess:
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
- def test_editor_oauth_edit_without_reconnect_keeps_actions(self, app, pg_conn):
+ def test_editor_oauth_save_without_reconnect_is_owner_only(self, app, pg_conn):
+ # A shared OAuth server's connection is the owner's: an editor can't
+ # save it, even with the same URL (the path, scopes or client could
+ # still change while reusing the owner's tokens).
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, "ed", body)
- assert resp.status_code == 200, resp.json
- row = _row(pg_conn, tool["id"])
- assert row["display_name"] == "Renamed"
- assert [a["name"] for a in row["actions"]] == ["old"]
+ assert resp.status_code == 403
+ assert _row(pg_conn, tool["id"])["display_name"] == "M"
+
+ def test_editor_cannot_switch_oauth_server_to_other_auth(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
+ "transport_type": "http", "bearer_token": "mine"}}
+ assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
+ assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "oauth"
+
+ def test_editor_cannot_switch_server_to_oauth(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
+ "transport_type": "http"}}
+ assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
+
+ def test_owner_oauth_save_needs_a_completed_sign_in(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ body = {"id": str(tool["id"]), "displayName": "Renamed",
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
+ "transport_type": "http"}}
+ resp, _ = self._save(app, pg_conn, OWNER, body)
+ assert resp.status_code == 400
+ assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
from docsgpt.api.user.tools.mcp import MCPAuthStatus
@@ -562,6 +589,12 @@ class TestMCPTestEndpointAccess:
assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
+ def test_editor_test_of_stored_oauth_server_is_owner_only(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ _share(pg_conn, tool["id"], "ed", "editor")
+ resp, cls = self._test(app, pg_conn, "ed", self._body(tool, bearer_token="mine"))
+ assert resp.status_code == 403 and not cls.called
+
def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
@@ -569,3 +602,62 @@ class TestMCPTestEndpointAccess:
"auth_type": "oauth", "transport_type": "http"}}
resp, cls = self._test(app, pg_conn, "ed", body)
assert resp.status_code == 403 and not cls.called
+
+
+# ---------------------------------------------------------------------------
+# 6. Shared OAuth MCP servers: the connection is the owner's
+# ---------------------------------------------------------------------------
+class TestSharedOAuthMCPConfig:
+ """``/api/update_tool`` and ``/api/update_tool_config`` can't move a shared
+ OAuth server or switch a shared server to OAuth: OAuth tokens are looked up
+ by owner + server URL, so either would run on the owner's sign-in
+ elsewhere. Connection changes on OAuth servers are the owner's."""
+
+ OTHER = {"server_url": "https://other-mcp.example.org/mcp", "auth_type": "oauth"}
+
+ def _oauth_tool(self, conn):
+ tool = _mcp_tool(conn, auth_type="oauth")
+ _share(conn, tool["id"], "ed", "editor")
+ return tool
+
+ def test_update_tool_config_cannot_move_oauth_server(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ tool = self._oauth_tool(pg_conn)
+ body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
+ assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
+
+ def test_update_tool_cannot_move_oauth_server(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateTool
+
+ tool = self._oauth_tool(pg_conn)
+ body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
+
+ def test_update_tool_config_cannot_switch_server_to_oauth(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]),
+ "config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth"}}
+ assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
+ assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "bearer"
+
+ def test_editor_still_renames_oauth_server_without_config(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateTool
+
+ tool = self._oauth_tool(pg_conn)
+ body = {"id": str(tool["id"]), "customName": "Renamed"}
+ assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
+
+ def test_owner_may_change_oauth_server_config(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ tool = _mcp_tool(pg_conn, auth_type="oauth")
+ body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
+ assert _call(app, pg_conn, UpdateToolConfig, OWNER, json=body).status_code == 200
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == self.OTHER["server_url"]
From 39f636a13737b43a03413467db63b5848de9222f Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 15:33:58 +0400
Subject: [PATCH 8/9] Fix rabbit
---
docsgpt/api/user/agents/routes.py | 29 ++++++++++++------------
tests/api/user/test_attach_owner_refs.py | 19 ++++++++++++++++
2 files changed, 34 insertions(+), 14 deletions(-)
diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py
index 47b1e3c7..2715b842 100644
--- a/docsgpt/api/user/agents/routes.py
+++ b/docsgpt/api/user/agents/routes.py
@@ -1242,7 +1242,7 @@ class UpdateAgent(Resource):
if not normalized:
if workflow_required:
return _reject("Workflow is required", user, field)
- update_fields["workflow_id"] = None
+ pg_workflow_id = None
else:
# The agent runs its workflow as the owner, so it
# must be one of the owner's workflows.
@@ -1251,19 +1251,20 @@ class UpdateAgent(Resource):
)
if wf_err:
return wf_err
- # Only the owner may point the agent at a different
- # workflow: editing rights on this agent extend to
- # the graph it uses, so swapping in the workflow of
- # another of the owner's agents would hand that
- # graph to the editor.
- current_workflow = existing_agent.get("workflow_id")
- if is_team_editor and pg_workflow_id != (
- str(current_workflow) if current_workflow else None
- ):
- return _denied(
- AccessDenied(403, "Only the owner can change this agent's workflow")
- )
- update_fields["workflow_id"] = pg_workflow_id
+ # Only the owner may change which workflow the agent
+ # uses, detaching included: editing rights on this
+ # agent extend to the graph it uses, so swapping in the
+ # workflow of another of the owner's agents would hand
+ # that graph to the editor. Editing the graph itself
+ # goes through the workflow routes.
+ current_workflow = existing_agent.get("workflow_id")
+ if is_team_editor and pg_workflow_id != (
+ str(current_workflow) if current_workflow else None
+ ):
+ return _denied(
+ AccessDenied(403, "Only the owner can change this agent's workflow")
+ )
+ update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
diff --git a/tests/api/user/test_attach_owner_refs.py b/tests/api/user/test_attach_owner_refs.py
index dad9276a..45a0c2cc 100644
--- a/tests/api/user/test_attach_owner_refs.py
+++ b/tests/api/user/test_attach_owner_refs.py
@@ -133,6 +133,25 @@ class TestAgentWorkflowSwap:
assert _status(_put(app, pg_conn, agent_a, OWNER, {"workflow": wf_b})) == 200
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_b
+ def test_editor_cannot_detach_workflow(self, app, pg_conn):
+ # Unpublishing in the same request makes the empty workflow allowed
+ # for a draft; detaching is still the owner's call.
+ agent_a, wf_a, _ = self._two_workflow_agents(pg_conn)
+ resp = _put(app, pg_conn, agent_a, EDITOR, {"status": "draft", "workflow": ""})
+ assert _status(resp) == 403
+ assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_a
+
+ def test_owner_may_detach_workflow(self, app, pg_conn):
+ agent_a, _, _ = self._two_workflow_agents(pg_conn)
+ resp = _put(app, pg_conn, agent_a, OWNER, {"status": "draft", "workflow": ""})
+ assert _status(resp) == 200, resp.get_json()
+ assert _row(pg_conn, agent_a)["workflow_id"] is None
+
+ def test_editor_empty_workflow_on_agent_without_one_is_a_no_op(self, app, pg_conn):
+ agent_id, _ = _agent(pg_conn)
+ resp = _put(app, pg_conn, agent_id, EDITOR, {"status": "draft", "workflow": ""})
+ assert _status(resp) == 200, resp.get_json()
+
class TestWorkflowNodeAttach:
def _setup(self, pg_conn):
From 258c3fb352048c72441cf4308165297b5fdffffc Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 16:06:05 +0400
Subject: [PATCH 9/9] Mcp fix
---
docsgpt/api/user/tools/mcp.py | 7 +--
docsgpt/api/user/tools/routes.py | 39 +++++++++++------
frontend/src/modals/MCPServerModal.test.tsx | 15 +++++++
frontend/src/modals/MCPServerModal.tsx | 16 ++++---
tests/api/user/test_tools_access.py | 48 +++++++++++++++++++++
5 files changed, 104 insertions(+), 21 deletions(-)
diff --git a/docsgpt/api/user/tools/mcp.py b/docsgpt/api/user/tools/mcp.py
index c28164f1..5f284c82 100644
--- a/docsgpt/api/user/tools/mcp.py
+++ b/docsgpt/api/user/tools/mcp.py
@@ -12,7 +12,7 @@ from docsgpt.api.user.team_sharing import visible_with_access
from docsgpt.api.user.tools.routes import (
_CREDENTIALS_FOR_NEW_SERVER,
_MCP_CREDENTIAL_AUTH_TYPES,
- _mcp_host_changed,
+ _mcp_origin_changed,
check_oauth_mcp_owner_only,
denied_response,
transform_actions,
@@ -90,7 +90,8 @@ def _existing_mcp_context(tool_id, user, config):
With no ``tool_id`` the caller acts on their own new server. With one,
the caller needs ``edit_credentials`` on that tool and everything runs as
its owner. Stored secrets are write-only, so an empty secret field reuses
- the stored one while the host is unchanged; a new host never inherits them.
+ the stored one while the origin (scheme, host, port) is unchanged; a new
+ origin never inherits them.
A server that is or would become OAuth is the owner's alone (its tokens
are the owner's sign-in).
@@ -114,7 +115,7 @@ def _existing_mcp_context(tool_id, user, config):
)
existing_config = existing_doc.get("config") or {}
check_oauth_mcp_owner_only(ra, existing_config, config)
- moved = _mcp_host_changed(config, existing_config)
+ moved = _mcp_origin_changed(config, existing_config)
auth_type = config.get("auth_type", "none")
new_secret_keys = set(auth_credentials) - {"api_key_header"}
if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py
index 8528ba64..25d826e1 100644
--- a/docsgpt/api/user/tools/routes.py
+++ b/docsgpt/api/user/tools/routes.py
@@ -193,7 +193,7 @@ _META_KEYS = ("name", "displayName", "customName", "description", "actions")
class CredentialsRequired(Exception):
- """A save moved a tool to a new host without supplying new secrets."""
+ """A save moved a tool to a new origin without supplying new secrets."""
def denied_response(err: AccessDenied):
@@ -207,12 +207,26 @@ def check_action(ra: ResourceAccess, action: str) -> None:
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
-def url_host(url: Any) -> str:
- """Lower-cased host of ``url`` ('' when it has none)."""
+_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
+
+
+def url_origin(url: Any) -> str:
+ """``scheme://host:port`` of ``url``, lower-cased, default port filled in.
+
+ Saved secrets follow the origin, not just the host: the same host over
+ ``http`` would send them in cleartext, and another port can be another
+ service. ``''`` when the URL has no host or doesn't parse.
+ """
try:
- return (urlparse(str(url or "").strip()).hostname or "").lower()
+ parts = urlparse(str(url or "").strip())
+ host = (parts.hostname or "").lower()
+ port = parts.port
except ValueError:
return ""
+ if not host:
+ return ""
+ scheme = (parts.scheme or "").lower()
+ return f"{scheme}://{host}:{port or _DEFAULT_PORTS.get(scheme, '')}"
def _has_value(value: Any) -> bool:
@@ -270,7 +284,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
An incoming entry with a value replaces the stored one; an empty value with
``has_value`` keeps it (legacy plaintext values included); anything else
- clears it. When an action's URL host changes the stored values are not
+ clears it. When an action's URL origin changes the stored values are not
carried over.
Args:
@@ -282,7 +296,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
The config to persist.
Raises:
- CredentialsRequired: a host changed, the client asked to keep a value,
+ CredentialsRequired: an origin changed, the client asked to keep a value,
and there is nothing to keep.
"""
existing_config = existing_config or {}
@@ -293,7 +307,7 @@ def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: st
sealed: dict = {}
for name, action in (out.get("actions") or {}).items():
old = old_actions.get(name) if isinstance(old_actions, dict) else None
- moved = isinstance(old, dict) and url_host(old.get("url")) != url_host(
+ moved = isinstance(old, dict) and url_origin(old.get("url")) != url_origin(
action.get("url") if isinstance(action, dict) else ""
)
prior = {} if moved else stored.get(name, {})
@@ -345,9 +359,10 @@ def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict)
return False
-def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
+def _mcp_origin_changed(new_config: dict, existing_config: dict) -> bool:
+ """Whether a save moves an MCP server to another scheme, host or port."""
old_url = (existing_config or {}).get("server_url")
- return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
+ return bool(old_url) and url_origin(old_url) != url_origin((new_config or {}).get("server_url"))
SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
@@ -386,16 +401,16 @@ def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements:
Handles the three secret stores: ``config_requirements`` secrets
(``encrypted_credentials``), api_tool header/query values, and the MCP
- host-change rule (a new server host drops stored credentials).
+ origin-change rule (a new scheme, host or port drops stored credentials).
Raises:
- CredentialsRequired: the MCP host changed and no new secret arrived.
+ CredentialsRequired: the MCP origin changed and no new secret arrived.
"""
owner_id = tool_doc["user_id"]
existing_config = tool_doc.get("config") or {}
if tool_doc.get("name") == "api_tool":
return _seal_api_tool_secrets(new_config, existing_config, owner_id)
- moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config)
+ moved = tool_doc.get("name") == "mcp_tool" and _mcp_origin_changed(new_config, existing_config)
if moved:
existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)
diff --git a/frontend/src/modals/MCPServerModal.test.tsx b/frontend/src/modals/MCPServerModal.test.tsx
index b7a1c8c8..54236f20 100644
--- a/frontend/src/modals/MCPServerModal.test.tsx
+++ b/frontend/src/modals/MCPServerModal.test.tsx
@@ -167,6 +167,21 @@ describe('MCPServerModal', () => {
expect(text()).toContain('settings.tools.mcp.errors.apiKeyRequired');
});
+ it.each([
+ ['a downgrade to http', 'http://mcp.dana-tools.dev/sse'],
+ ['another port', 'https://mcp.dana-tools.dev:8443/sse'],
+ ])('clears the saved key for %s', async (_label, url) => {
+ await render();
+ await typeInto(urlInput(), url);
+ expect(text()).toContain('settings.tools.mcp.serverChangedNotice');
+ });
+
+ it('keeps the saved key when only the default port is spelled out', async () => {
+ await render();
+ await typeInto(urlInput(), 'https://mcp.dana-tools.dev:443/v2/sse');
+ expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice');
+ });
+
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');
diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx
index accc16c7..ba99ddfe 100644
--- a/frontend/src/modals/MCPServerModal.tsx
+++ b/frontend/src/modals/MCPServerModal.tsx
@@ -30,10 +30,14 @@ interface MCPServerModalProps {
onServerSaved: () => void;
}
-/** The host of a URL, or '' while it doesn't parse. */
-function hostOf(url: string): string {
+/**
+ * The origin (scheme, host, port) of a URL, or '' while it doesn't parse.
+ * Saved secrets follow the origin, like the server's rule: the same host over
+ * http would send them in cleartext, and another port can be another service.
+ */
+function originOf(url: string): string {
try {
- return new URL(url.trim()).host.toLowerCase();
+ return new URL(url.trim()).origin.toLowerCase();
} catch {
return '';
}
@@ -123,11 +127,11 @@ export default function MCPServerModal({
!!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.
+ // The server clears the saved secret when the origin changes, so the key
+ // can't be pointed at another server, port or plain http.
const serverChanged =
hasSavedSecret &&
- hostOf(formData.server_url) !== hostOf(server?.server_url || '');
+ originOf(formData.server_url) !== originOf(server?.server_url || '');
const keepSavedSecret = hasSavedSecret && !serverChanged;
const cleanupOAuthListener = useCallback(() => {
diff --git a/tests/api/user/test_tools_access.py b/tests/api/user/test_tools_access.py
index b404f0e0..27ba7b4d 100644
--- a/tests/api/user/test_tools_access.py
+++ b/tests/api/user/test_tools_access.py
@@ -262,6 +262,24 @@ class TestApiToolSecrets:
assert self._save(app, pg_conn, OWNER, tool["id"], same_host).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+ @pytest.mark.parametrize("url", [
+ "http://api.example.com/users", # https -> http: cleartext
+ "https://api.example.com:8443/users", # another port, maybe another service
+ ])
+ def test_url_scheme_or_port_change_requires_new_secrets(self, app, pg_conn, url):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ resp = self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config(url=url)))
+ assert resp.status_code == 400
+ assert "credentials" in resp.json["message"].lower()
+
+ def test_url_with_explicit_default_port_keeps_secrets(self, app, pg_conn):
+ tool = _tool(pg_conn, name="api_tool", config={})
+ self._save(app, pg_conn, OWNER, tool["id"], _api_config())
+ same = self._masked(_api_config(url="https://API.example.com:443/users"))
+ assert self._save(app, pg_conn, OWNER, tool["id"], same).status_code == 200
+ assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
+
def test_url_host_change_with_new_secrets_succeeds(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
@@ -326,6 +344,18 @@ class TestToolWrites:
stored = _row(pg_conn, tool["id"])["config"]
assert stored["server_url"] == "https://mcp.example.com/x"
+ def test_update_tool_config_scheme_change_clears_stored_secrets(self, app, pg_conn):
+ from docsgpt.api.user.tools.routes import UpdateToolConfig
+
+ cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
+ "encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
+ tool = _tool(pg_conn, name="mcp_tool", config=cfg)
+ body = {"id": str(tool["id"]), "config": {"server_url": "http://mcp.example.com/x",
+ "auth_type": "bearer"}}
+ resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
+ assert resp.status_code == 400
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/x"
+
def test_api_tool_description_edit_needs_only_edit(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool, _seal_api_tool_secrets
@@ -475,6 +505,17 @@ class TestMCPSaveAccess:
assert not cls.called
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
+ @pytest.mark.parametrize("url", ["http://mcp.example.com/mcp", "https://mcp.example.com:8443/mcp"])
+ def test_scheme_or_port_change_without_credentials_is_rejected(self, app, pg_conn, url):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ body = {"id": str(tool["id"]), "displayName": "M",
+ "config": {"server_url": url, "auth_type": "bearer", "transport_type": "http"}}
+ resp, cls = self._save(app, pg_conn, "ed", body)
+ assert resp.status_code == 400
+ assert not cls.called
+ assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
+
def test_host_change_with_new_credentials_replaces_them(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
@@ -583,6 +624,13 @@ class TestMCPTestEndpointAccess:
bearer_token="t2"))
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "t2"}
+ def test_scheme_downgrade_without_secret_is_400(self, app, pg_conn):
+ tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
+ _share(pg_conn, tool["id"], "ed", "editor")
+ resp, cls = self._test(app, pg_conn, "ed", self._body(tool, url="http://mcp.example.com/mcp"))
+ assert resp.status_code == 400
+ assert not cls.called
+
def test_viewer_and_stranger_denied(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")