From d37e6bec51d80f679d4b8696bbaffb992d8a8c1c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 16 Jun 2026 12:14:28 +0100 Subject: [PATCH] feat: better tags and dropdowns for sharing --- application/api/user/sources/routes.py | 15 ++++++- .../storage/db/repositories/sources.py | 40 +++++++++++++++++-- frontend/src/agents/NewAgent.tsx | 17 ++++++++ frontend/src/locale/en.json | 4 ++ frontend/src/models/misc.ts | 3 ++ frontend/src/settings/Sources.tsx | 12 ++++++ frontend/src/settings/Tools.tsx | 12 ++++++ frontend/src/settings/types/index.ts | 3 ++ 8 files changed, 102 insertions(+), 4 deletions(-) diff --git a/application/api/user/sources/routes.py b/application/api/user/sources/routes.py index d45909a5..cbd6ea47 100644 --- a/application/api/user/sources/routes.py +++ b/application/api/user/sources/routes.py @@ -132,8 +132,13 @@ class PaginatedSources(Resource): try: with db_readonly() as conn: repo = SourcesRepository(conn) + # Include sources shared with the caller's teams so the settings + # list matches the /api/sources dropdown. Unioned into the query + # by id so count/sort/search/pagination stay correct. + team_shared = visible_with_access(conn, user, "source") + extra_ids = list(team_shared.keys()) total_documents = repo.count_for_user( - user, search_term=search_term, + user, search_term=search_term, extra_ids=extra_ids, ) # Prior in-Python implementation returned ``totalPages = 1`` # for empty result sets (``max(1, ceil(0/rows))``); we @@ -148,11 +153,15 @@ class PaginatedSources(Resource): search_term=search_term, sort_field=sort_field, sort_order=sort_order, + extra_ids=extra_ids, ) paginated_docs = [] for doc in window: provider = _get_provider_from_remote_data(doc.get("remote_data")) + # 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) paginated_docs.append( { "id": str(doc["id"]), @@ -168,6 +177,10 @@ class PaginatedSources(Resource): "type": doc.get("type", "file"), # 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"])) + ), } ) response = { diff --git a/application/storage/db/repositories/sources.py b/application/storage/db/repositories/sources.py index f7898867..b20044c2 100644 --- a/application/storage/db/repositories/sources.py +++ b/application/storage/db/repositories/sources.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +import uuid from typing import Any, Optional -from sqlalchemy import case, Connection, func, select, text +from sqlalchemy import case, Connection, func, or_, select, text from application.storage.db.base_repository import looks_like_uuid, row_to_dict from application.storage.db.models import ingest_chunk_progress_table, sources_table @@ -24,6 +25,37 @@ _ALLOWED_COLUMNS = _SCALAR_COLUMNS | _JSONB_COLUMNS _SORTABLE_COLUMNS = {"date", "name", "tokens", "type", "created_at", "updated_at"} +def _coerce_uuid_ids(extra_ids: Optional[list]) -> list: + """Coerce id strings to ``uuid.UUID`` for binding against UUID columns. + + Non-UUID-looking ids (e.g. legacy/synthetic) are dropped — they can never + match a ``sources.id`` value. Used to OR team-shared source ids into the + owner-scoped queries. + """ + if not extra_ids: + return [] + out: list = [] + for raw in extra_ids: + s = str(raw) + if looks_like_uuid(s): + out.append(uuid.UUID(s)) + return out + + +def _owned_or_shared_scope(user_id: str, extra_ids: Optional[list]): + """WHERE predicate matching the owner's rows plus any ``extra_ids``. + + Lets the paginated/count queries include team-shared sources (passed by id) + alongside owned ones in a single query, so count/sort/search/pagination + stay correct across the union. + """ + scope = sources_table.c.user_id == user_id + ids = _coerce_uuid_ids(extra_ids) + if ids: + scope = or_(scope, sources_table.c.id.in_(ids)) + return scope + + def _escape_like(pattern: str) -> str: """Escape wildcards so a user-supplied substring is matched literally. @@ -228,6 +260,7 @@ class SourcesRepository: search_term: Optional[str] = None, sort_field: str = "created_at", sort_order: str = "desc", + extra_ids: Optional[list] = None, ) -> list[dict]: """Return sources owned by ``user_id``, paginated and optionally filtered. @@ -269,7 +302,7 @@ class SourcesRepository: == sources_table.c.id, ) ) - .where(sources_table.c.user_id == user_id) + .where(_owned_or_shared_scope(user_id, extra_ids)) ) if search_term: stmt = stmt.where( @@ -298,6 +331,7 @@ class SourcesRepository: user_id: str, *, search_term: Optional[str] = None, + extra_ids: Optional[list] = None, ) -> int: """Return the count of rows that ``list_for_user`` would produce. @@ -315,7 +349,7 @@ class SourcesRepository: stmt = ( select(func.count()) .select_from(sources_table) - .where(sources_table.c.user_id == user_id) + .where(_owned_or_shared_scope(user_id, extra_ids)) ) if search_term: stmt = stmt.where( diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index f5456362..5f2b6244 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -35,6 +35,7 @@ import { import Spinner from '../components/Spinner'; import ToolIcon from '../components/ToolIcon'; import AgentDetailsModal from '../modals/AgentDetailsModal'; +import ShareToTeamModal from '../teams/ShareToTeamModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState, Doc, Prompt } from '../models/misc'; import { @@ -117,6 +118,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const [agentDetails, setAgentDetails] = useState('INACTIVE'); + const [shareModalOpen, setShareModalOpen] = useState(false); const [addPromptModal, setAddPromptModal] = useState('INACTIVE'); const [hasChanges, setHasChanges] = useState(false); const [draftLoading, setDraftLoading] = useState(false); @@ -863,6 +865,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setAgentDetails('ACTIVE')}> {t('agents.form.buttons.accessDetails')} + {/* Sharing is owner-only — hidden for agents shared into the + workspace by a team (ownership === 'team'). */} + {agent.ownership !== 'team' && agent.id && ( + setShareModalOpen(true)}> + {t('agents.shareWithTeam')} + + )} )} @@ -1465,6 +1474,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { modalState={agentDetails} setModalState={setAgentDetails} /> + {shareModalOpen && agent.id && ( + setShareModalOpen(false)} + /> + )}
+ {document.ownership === 'team' && ( + + + )} {document.ingestStatus === 'failed' && ( {t('settings.sources.ingestFailed')} diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 916b4344..2960ee18 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -451,6 +451,18 @@ export default function Tools() { )} )} + {tool.ownership === 'team' && ( + + + )}