feat: better tags and dropdowns for sharing

This commit is contained in:
Alex committed 2026-06-16 12:14:28 +01:00
1 parent b97a12870c
commit d37e6bec51
8 files changed
+102 -4

No files matched your search

+14 -1
View File
@@ -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 = {
+37 -3
View File
@@ -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(
+17
View File
@@ -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<ActiveState>('INACTIVE');
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
const [shareModalOpen, setShareModalOpen] = useState(false);
const [addPromptModal, setAddPromptModal] = useState<ActiveState>('INACTIVE');
const [hasChanges, setHasChanges] = useState(false);
const [draftLoading, setDraftLoading] = useState(false);
@@ -863,6 +865,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<DropdownMenuItem onSelect={() => setAgentDetails('ACTIVE')}>
{t('agents.form.buttons.accessDetails')}
</DropdownMenuItem>
{/* Sharing is owner-only — hidden for agents shared into the
workspace by a team (ownership === 'team'). */}
{agent.ownership !== 'team' && agent.id && (
<DropdownMenuItem onSelect={() => setShareModalOpen(true)}>
{t('agents.shareWithTeam')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -1465,6 +1474,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
modalState={agentDetails}
setModalState={setAgentDetails}
/>
{shareModalOpen && agent.id && (
<ShareToTeamModal
resourceType="agent"
resourceId={agent.id}
resourceName={agent.name}
onClose={() => setShareModalOpen(false)}
/>
)}
<AddPromptModal
prompts={prompts}
isOpen={addPromptModal}
+4
View File
@@ -12,6 +12,10 @@
"emailUs": "Email us",
"documentation": "Documentation",
"manageAgents": "Manage Agents",
"teamAccess": {
"editor": "Editor",
"viewer": "Viewer"
},
"demo": [
{
"header": "Learn about DocsGPT",
+3
View File
@@ -19,6 +19,9 @@ export type Doc = {
// Whether the current user owns this source ('user') or only has access to
// it via a team share ('team'). Owner-only actions are gated on 'user'.
ownership?: 'user' | 'team';
// 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;
};
export type GetDocsResponse = {
+12
View File
@@ -611,6 +611,18 @@ export default function Sources({
</div>
<div className="flex flex-col items-start justify-start gap-1">
{document.ownership === 'team' && (
<span className="bg-muted-foreground/10 text-muted-foreground flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-[16px] font-medium">
<Users
size={11}
strokeWidth={2}
aria-hidden="true"
/>
{document.team_access === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</span>
)}
{document.ingestStatus === 'failed' && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs leading-[16px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
{t('settings.sources.ingestFailed')}
+12
View File
@@ -451,6 +451,18 @@ export default function Tools() {
)}
</span>
)}
{tool.ownership === 'team' && (
<span className="bg-muted-foreground/10 text-muted-foreground inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs leading-none font-medium">
<Users
size={11}
strokeWidth={2}
aria-hidden="true"
/>
{tool.team_access === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer')}
</span>
)}
</div>
<div className="mt-[9px]">
<p
+3
View File
@@ -90,6 +90,9 @@ export type UserToolType = {
// Whether the current user owns this tool ('user') or only has access to
// it via a team share ('team'). Owner-only actions are gated on 'user'.
ownership?: 'user' | 'team';
// 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;
config: {
[key: string]: any;
};