Merge pull request #2548 from arc53/team

feat: teams
This commit is contained in:
Alex authored and GitHub committed 2026-06-17 10:21:42 +01:00
commit b7a8eaeb9f
65 files changed
+7082 -201

No files matched your search

+6
View File
@@ -109,6 +109,12 @@ celerybeat.pid
.env
.env.local
.env.*.local
# Backups of env files (e.g. .env.local.bak, .env.bak.1781108407)
*.env.bak
*.env.bak.*
.env.bak
.env.bak.*
.env.local.bak
.venv
# Machine-specific Claude Code guidance (see CLAUDE.md preamble)
CLAUDE.md
+24 -2
View File
@@ -842,9 +842,31 @@ class ToolExecutor:
)
else:
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
if tool_config.get("encrypted_credentials") and self.user:
# Credentials are PBKDF2-bound to the tool OWNER's sub, not the
# invoker's. Decrypt with the tool row's user_id so a team member
# running an owner's shared tool authenticates with the owner's
# credentials (deliberate delegation — see teams-spec OQ2), and so
# the long-standing agent-key path (tools resolved by owner) stops
# silently decrypt-failing. Falls back to self.user for the
# agentless path where the tool row carries no user_id.
tool_owner = tool_data.get("user_id") or self.user
if tool_config.get("encrypted_credentials") and tool_owner:
if tool_owner != self.user:
# Credential delegation: the invoker is running a shared
# tool with the owner's secrets. Audit it (the agent-run
# authorization upstream is the access boundary).
logger.info(
"tool_credential_delegation",
extra={
"invoker": self.user,
"tool_owner": tool_owner,
"tool_id": str(tool_data.get("id") or tool_id),
"tool_name": tool_data.get("name"),
"agent_id": self.agent_id,
},
)
decrypted = decrypt_credentials(
tool_config["encrypted_credentials"], self.user
tool_config["encrypted_credentials"], tool_owner
)
tool_config.update(decrypted)
tool_config["auth_credentials"] = decrypted
+16 -1
View File
@@ -72,7 +72,17 @@ class InternalSearchTool(Tool):
active_docs = [active_docs]
decoded_token = self.config.get("decoded_token") or {}
user_id = decoded_token.get("sub") if decoded_token else None
# Resolve the agent's sources as their OWNER: for a team-shared
# agent run by a member, the sources belong to the owner, so using
# the member's sub would 404. ``source_owner_id`` is the agent owner
# (set at config-build time); fall back to the BYOM model_user_id,
# then the invoker. Running the agent already authorized these
# sources.
user_id = (
self.config.get("source_owner_id")
or self.config.get("model_user_id")
or (decoded_token.get("sub") if decoded_token else None)
)
merged_structure = {}
with db_readonly() as conn:
@@ -439,6 +449,7 @@ def build_internal_tool_config(
doc_token_limit: int = 50000,
model_id: str = "docsgpt-local",
model_user_id: Optional[str] = None,
source_owner_id: Optional[str] = None,
user_api_key: Optional[str] = None,
agent_id: Optional[str] = None,
llm_name: str = None,
@@ -454,6 +465,10 @@ def build_internal_tool_config(
"doc_token_limit": doc_token_limit,
"model_id": model_id,
"model_user_id": model_user_id,
# The agent owner — the sources belong to them, so directory-structure
# resolution uses this (a team member running a shared agent has a
# different sub). Independent of the BYOM ``model_user_id``.
"source_owner_id": source_owner_id,
"user_api_key": user_api_key,
"agent_id": agent_id,
"llm_name": llm_name or settings.LLM_PROVIDER,
+181
View File
@@ -0,0 +1,181 @@
"""0021 teams — multi-team membership, team-scoped roles, resource sharing.
Adds the whole Teams feature in one migration:
* ``teams`` — a team. ``owner_id`` is the creator's auth ``sub`` and the durable
"who can delete the team" anchor. No FK/trigger on it (mirroring ``user_roles``
/ ``auth_events``): an ``ON DELETE RESTRICT`` FK would block user deletion
(SCIM/GDPR) and force an owner-reassignment dance on every delete.
* ``team_members`` — membership + team-scoped role grant, field-for-field on
``user_roles``. ``(team_id, user_id, role, source)`` PK so a manual grant and a
future IdP-derived grant coexist and revoke independently. The ``source`` CHECK
already carries ``oidc_group``/``scim`` so those phases need no migration.
* ``team_resource_grants`` — one polymorphic share table covering all four
shareable resource types. ``owner_id`` is denormalised owner-at-share-time so
visibility queries never re-join the resource table. ``target_user_id`` is NULL
for a whole-team share or a member's ``sub`` for a per-member share; the dedup
index is functional over ``COALESCE(target_user_id, '')`` so a whole-team grant
and any number of per-member grants for the same (team, resource) coexist.
Sharing is additive visibility, never ownership transfer.
Grant rows have no cross-table FK (the resource_id is polymorphic), so an
``AFTER DELETE`` trigger on each resource table scrubs dangling grants — covering
the non-route delete paths (reconciler / sync) that bypass app cleanup.
Also adds ``users.email`` (populated from the OIDC email claim at login) so a
team admin can add a member by email instead of pasting a raw ``sub``.
Revision ID: 0021_teams
Revises: 0020_user_roles
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0021_teams"
down_revision: Union[str, None] = "0020_user_roles"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_RESOURCE_TABLES = ("agents", "sources", "prompts", "user_tools")
_RESOURCE_TYPES = {
"agents": "agent",
"sources": "source",
"prompts": "prompt",
"user_tools": "tool",
}
def upgrade() -> None:
# --- teams ---------------------------------------------------------------
op.execute(
"""
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug CITEXT NOT NULL,
description TEXT,
owner_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS teams_slug_uidx ON teams (slug);")
op.execute("CREATE INDEX IF NOT EXISTS teams_owner_idx ON teams (owner_id);")
# Reuse the shared set_updated_at() trigger fn defined in 0001.
op.execute("DROP TRIGGER IF EXISTS teams_set_updated_at ON teams;")
op.execute(
"""
CREATE TRIGGER teams_set_updated_at
BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
"""
)
# --- team_members --------------------------------------------------------
op.execute(
"""
CREATE TABLE IF NOT EXISTS team_members (
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
role TEXT NOT NULL
CHECK (role IN ('team_admin', 'team_member')),
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'oidc_group', 'scim')),
granted_by TEXT,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id, role, source)
);
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS team_members_user_idx ON team_members (user_id);"
)
# --- team_resource_grants ------------------------------------------------
op.execute(
"""
CREATE TABLE IF NOT EXISTS team_resource_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL
CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')),
resource_id UUID NOT NULL,
owner_id TEXT NOT NULL,
access_level TEXT NOT NULL DEFAULT 'viewer'
CHECK (access_level IN ('viewer', 'editor')),
target_user_id TEXT,
granted_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
)
# Functional dedup: whole-team (target NULL → '') and each per-member grant
# are distinct keys.
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS team_resource_grants_dedup_uidx
ON team_resource_grants
(team_id, resource_type, resource_id, COALESCE(target_user_id, ''));
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS team_resource_grants_team_type_idx
ON team_resource_grants (team_id, resource_type);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS team_resource_grants_resource_idx
ON team_resource_grants (resource_type, resource_id);
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS team_resource_grants_target_idx
ON team_resource_grants (target_user_id)
WHERE target_user_id IS NOT NULL;
"""
)
# --- dangling-grant cleanup ---------------------------------------------
op.execute(
"""
CREATE OR REPLACE FUNCTION cleanup_team_resource_grants() RETURNS trigger AS $$
BEGIN
DELETE FROM team_resource_grants
WHERE resource_type = TG_ARGV[0] AND resource_id = OLD.id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
"""
)
for table in _RESOURCE_TABLES:
trig = f"{table}_cleanup_team_grants"
op.execute(f"DROP TRIGGER IF EXISTS {trig} ON {table};")
op.execute(
f"CREATE TRIGGER {trig} AFTER DELETE ON {table} "
f"FOR EACH ROW EXECUTE FUNCTION cleanup_team_resource_grants('{_RESOURCE_TYPES[table]}');"
)
# --- users.email (add-member-by-email) -----------------------------------
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;")
op.execute(
"CREATE INDEX IF NOT EXISTS users_email_lower_idx ON users (lower(email));"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS users_email_lower_idx;")
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS email;")
for table in _RESOURCE_TABLES:
op.execute(f"DROP TRIGGER IF EXISTS {table}_cleanup_team_grants ON {table};")
op.execute("DROP FUNCTION IF EXISTS cleanup_team_resource_grants();")
op.execute("DROP TABLE IF EXISTS team_resource_grants;")
op.execute("DROP TABLE IF EXISTS team_members;")
op.execute("DROP TABLE IF EXISTS teams;")
@@ -28,6 +28,7 @@ from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.attachments import AttachmentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.team_scope import TeamScopeRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
@@ -427,7 +428,7 @@ class StreamProcessor:
with db_readonly() as conn:
# Lookup without user scoping — access control is done
# against ``user_id`` / ``shared_with`` / ``shared`` flags
# right below, matching the legacy Mongo semantics.
# below, matching the legacy Mongo semantics.
repo = AgentsRepository(conn)
agent = None
if looks_like_uuid(str(agent_id)):
@@ -442,13 +443,29 @@ class StreamProcessor:
agent = row_to_dict(row)
if agent is None:
agent = repo.get_by_legacy_id(str(agent_id))
if agent is None:
raise Exception("Agent not found")
agent_owner = agent.get("user_id")
is_owner = agent_owner == user_id
is_shared_with_user = bool(agent.get("shared", False))
if agent is None:
raise Exception("Agent not found")
agent_owner = agent.get("user_id")
is_owner = agent_owner == user_id
is_shared_with_user = bool(agent.get("shared", False))
if not (is_owner or is_shared_with_user):
# Team-shared agents are runnable by any member with a grant
# (viewer is enough to run). Resolved live against team_members
# on the SAME connection so a revoked grant/membership denies on
# the next call; resolution failure fails closed.
is_team_shared = False
if not (is_owner or is_shared_with_user) and user_id:
try:
is_team_shared = TeamScopeRepository(conn).can_read(
user_id, "agent", str(agent["id"])
)
except Exception:
logger.error(
"team access check failed for agent run", exc_info=True
)
is_team_shared = False
if not (is_owner or is_shared_with_user or is_team_shared):
raise Exception("Unauthorized access to the agent")
if is_owner:
now = datetime.datetime.now(datetime.timezone.utc)
@@ -1362,6 +1379,10 @@ class StreamProcessor:
),
"model_id": self.model_id,
"model_user_id": self.model_user_id,
# Agent owner — internal_search resolves the agent's sources as
# their owner so a team member running a shared agent can read
# nested-source structure (the sources aren't theirs).
"source_owner_id": self.agent_config.get("user_id"),
"user_api_key": self.agent_config["user_api_key"],
"agent_id": self.agent_id,
"llm_name": provider or settings.LLM_PROVIDER,
+7 -1
View File
@@ -230,7 +230,13 @@ def _gate_and_audit_login(user_id: str, effective: dict, groups: list[str]) -> b
)
else:
if row is None:
users.upsert(user_id)
# New user: provision with the email claim so a team admin
# can later add them by email.
users.upsert(user_id, email=effective.get("email"))
else:
# Existing user: targeted email backfill only (don't re-run
# the full upsert — preserves the no-upsert-on-login path).
users.set_email(user_id, effective.get("email"))
AuthEventsRepository(conn).insert(
user_id,
"oidc_login",
+209 -13
View File
@@ -10,6 +10,8 @@ from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import (
handle_image_upload,
resolve_prompt_name,
resolve_source_details,
resolve_tool_details,
storage,
)
@@ -19,6 +21,11 @@ from application.core.json_schema_utils import (
)
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid
from application.api.user.team_sharing import (
can_access,
team_access_for,
visible_with_access,
)
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.users import UsersRepository
@@ -165,13 +172,25 @@ def _resolve_folder_id(conn, folder_id, user):
return str(folder["id"]), None
def _format_agent_output(agent: dict, *, pinned: bool = False, include_key_masked: bool = True) -> dict:
def _format_agent_output(
agent: dict,
*,
pinned: bool = False,
include_key_masked: bool = True,
ownership: str = "user",
team_access: str | None = None,
resolve_names: bool = False,
) -> dict:
"""Shape a PG agent row into the outward API response dict.
Translates PG snake_case columns to the camelCase/frontend keys that
the React client expects, preserving ``source``/``sources`` naming on
the response even though storage uses ``source_id`` /
``extra_source_ids``.
``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.
"""
source_id = agent.get("source_id")
extra_source_ids = agent.get("extra_source_ids") or []
@@ -214,7 +233,23 @@ def _format_agent_output(agent: dict, *, pinned: bool = False, include_key_maske
"allow_system_prompt_override": bool(
agent.get("allow_system_prompt_override", False)
),
"ownership": ownership,
"team_access": team_access,
}
# 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
# the caller's own lists, which don't contain the owner's resources).
if resolve_names:
out["prompt_name"] = resolve_prompt_name(agent.get("prompt_id"))
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":
out["shared_token"] = ""
return out
if include_key_masked:
key_val = agent.get("key") or ""
out["key"] = (
@@ -303,11 +338,24 @@ class GetAgent(Resource):
return {"success": False, "message": "ID required"}, 400
try:
user = decoded_token["sub"]
ownership, team_access = "user", None
with db_readonly() as conn:
agent = AgentsRepository(conn).get_any(agent_id, user)
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"
if not agent:
return {"status": "Not found"}, 404
data = _format_agent_output(agent)
data = _format_agent_output(
agent,
ownership=ownership,
team_access=team_access,
resolve_names=True,
)
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
@@ -330,16 +378,35 @@ class GetAgents(Resource):
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
agents = AgentsRepository(conn).list_for_user(user)
list_agents = [
_format_agent_output(
agent, pinned=str(agent["id"]) in pinned_ids,
agents_repo = AgentsRepository(conn)
agents = agents_repo.list_for_user(user)
owned_ids = {str(a["id"]) for a in agents}
# Append agents shared with the caller's teams (dedup vs owned).
team_shared = visible_with_access(conn, user, "agent")
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
shared_agents = agents_repo.list_by_ids(shared_ids)
def _is_runnable(agent: dict) -> bool:
return bool(
agent.get("source_id")
or (agent.get("extra_source_ids") or [])
or agent.get("retriever")
or agent.get("agent_type") == "workflow"
)
list_agents = [
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
for agent in agents
if agent.get("source_id")
or (agent.get("extra_source_ids") or [])
or agent.get("retriever")
or agent.get("agent_type") == "workflow"
if _is_runnable(agent)
]
list_agents += [
_format_agent_output(
agent,
ownership="team",
team_access=team_shared.get(str(agent["id"])),
)
for agent in shared_agents
if _is_runnable(agent)
]
except Exception as err:
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
@@ -550,6 +617,27 @@ class CreateAgent(Resource):
if source_value and source_value != "default" and looks_like_uuid(source_value):
source_id_resolved = source_value
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
# access through a shared agent is a separate run-time concept,
# intentionally not gated here.)
referenced_sources = (
[source_id_resolved] if source_id_resolved else []
) + extra_source_ids
for sid in referenced_sources:
if not can_access(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}),
403,
)
prompt_ref = data.get("prompt_id")
if prompt_ref and prompt_ref != "default" and looks_like_uuid(prompt_ref):
if not can_access(conn, "prompt", prompt_ref, user):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}),
403,
)
build_data = dict(data)
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
@@ -699,7 +787,23 @@ 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,
)
if not existing_agent:
return make_response(
jsonify(
@@ -1066,8 +1170,100 @@ class UpdateAgent(Resource):
newly_generated_key = str(uuid.uuid4())
update_fields["key"] = newly_generated_key
# Apply update.
updated = agents_repo.update(pg_agent_id, user, update_fields)
# Re-validate referenced sources/prompt on write — a team editor
# must not ATTACH a source/prompt they can't access. References
# already on the agent (set by the owner) are exempt: an editor
# may keep them even when they aren't independently shared, so a
# plain save of an owner-configured agent isn't rejected — only
# NEWLY added refs need a grant. Owners pass can_access for their
# own resources regardless, so this carve-out is a no-op for them
# and the gate stays effective against an editor adding new refs.
# Mirrors the unchanged-tool carve-out below.
existing_source_refs = {
str(s)
for s in (
[existing_agent.get("source_id")]
+ list(existing_agent.get("extra_source_ids") or [])
)
if s
}
referenced_sources = (
([update_fields["source_id"]] if update_fields.get("source_id") else [])
+ (update_fields.get("extra_source_ids") or [])
)
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
if not can_access(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
new_prompt_id = update_fields.get("prompt_id")
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)
):
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 application.agents.default_tools import is_synthesized_tool_id
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):
continue
if not can_access(conn, "tool", tid_s, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
),
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",
):
update_fields.pop(_q, None)
# Apply update. Owner writes use the dual-key guard; team-editor
# writes go by-id (already authorized) with an optimistic-lock
# check when the client supplies the row's expected updated_at.
if is_team_editor:
result = agents_repo.update_by_id(
pg_agent_id,
update_fields,
expected_updated_at=data.get("expected_updated_at"),
)
if result is None:
return make_response(
jsonify(
{
"success": False,
"message": "Agent was modified by someone else",
"code": "stale_write",
}
),
409,
)
updated = bool(result)
else:
updated = agents_repo.update(pg_agent_id, user, update_fields)
if not updated:
return make_response(
jsonify(
+52
View File
@@ -167,6 +167,58 @@ def resolve_tool_details(tool_ids):
]
_BUILTIN_PROMPT_NAMES = {
"default": "Default",
"creative": "Creative",
"strict": "Strict",
}
def resolve_prompt_name(prompt_id) -> Optional[str]:
"""Resolve a prompt id to its display name by id (owner-agnostic).
Mirrors ``resolve_tool_details``: looks the prompt up by id without user
scoping, so a team member viewing a shared agent sees the OWNER's prompt
name rather than nothing. Built-in prompt sentinels map to friendly labels.
Returns None when the prompt is missing/unknown.
"""
if not prompt_id:
return None
pid = str(prompt_id)
if pid in _BUILTIN_PROMPT_NAMES:
return _BUILTIN_PROMPT_NAMES[pid]
if not looks_like_uuid(pid):
return None
with db_readonly() as conn:
result = conn.execute(
_sql_text("SELECT name FROM prompts WHERE id = CAST(:id AS uuid)"),
{"id": pid},
)
row = result.fetchone()
return row[0] if row is not None else None
def resolve_source_details(source_ids) -> list[dict]:
"""Resolve source ids to ``[{"id", "name"}]`` by id (owner-agnostic).
Order-preserving; an id with no matching source row yields ``name: None`` so
the client can fall back. Lets a team member viewing a shared agent see the
owner's source names instead of a raw id / "External KB".
"""
ids = [str(s) for s in (source_ids or []) if s and looks_like_uuid(str(s))]
if not ids:
return []
with db_readonly() as conn:
result = conn.execute(
_sql_text(
"SELECT id, name FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))"
),
{"ids": ids},
)
by_id = {str(r[0]): r[1] for r in result.fetchall()}
return [{"id": sid, "name": by_id.get(sid)} for sid in ids]
def get_vector_store(source_id):
"""
Get the Vector Store for a given source ID.
+56 -8
View File
@@ -7,6 +7,7 @@ from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import current_dir
from application.api.user.team_sharing import team_access_for, visible_with_access
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import check_required_fields
@@ -60,7 +61,12 @@ class GetPrompts(Resource):
user = decoded_token.get("sub")
try:
with db_readonly() as conn:
prompts = PromptsRepository(conn).list_for_user(user)
repo = PromptsRepository(conn)
prompts = repo.list_for_user(user)
owned_ids = {str(p["id"]) for p in prompts}
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)
list_prompts = [
{"id": "default", "name": "default", "type": "public"},
{"id": "creative", "name": "creative", "type": "public"},
@@ -74,6 +80,15 @@ class GetPrompts(Resource):
"type": "private",
}
)
for prompt in shared_prompts:
list_prompts.append(
{
"id": str(prompt["id"]),
"name": prompt["name"],
"type": "team",
"team_access": team_shared.get(str(prompt["id"])),
}
)
except Exception as err:
current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@@ -115,7 +130,11 @@ class GetSinglePrompt(Resource):
chat_reduce_strict = f.read()
return make_response(jsonify({"content": chat_reduce_strict}), 200)
with db_readonly() as conn:
prompt = PromptsRepository(conn).get_any(prompt_id, user)
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)
if not prompt:
return make_response(
jsonify({"success": False, "message": "Prompt not found"}), 404
@@ -190,12 +209,41 @@ class UpdatePrompt(Resource):
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.update(str(prompt["id"]), user, data["name"], data["content"])
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,
)
except Exception as err:
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
+4
View File
@@ -21,6 +21,7 @@ from .prompts import prompts_ns
from .schedules import schedules_ns
from .sharing import sharing_ns
from .sources import sources_chunks_ns, sources_ns, sources_upload_ns
from .teams import teams_ns
from .tools import tools_mcp_ns, tools_ns
from .workflows import workflows_ns
@@ -63,6 +64,9 @@ api.add_namespace(sources_ns)
api.add_namespace(sources_chunks_ns)
api.add_namespace(sources_upload_ns)
# Teams (CRUD, membership, resource-sharing grants)
api.add_namespace(teams_ns)
# Tools (main, MCP)
api.add_namespace(tools_ns)
api.add_namespace(tools_mcp_ns)
+26 -12
View File
@@ -5,6 +5,7 @@ from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import get_vector_store
from application.api.user.team_sharing import effective_write_owner
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.session import db_readonly
from application.utils import check_required_fields, num_tokens_from_string
@@ -23,6 +24,25 @@ def _resolve_source(doc_id: str, user: str):
return SourcesRepository(conn).get_any(doc_id, user)
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)
@sources_chunks_ns.route("/get_chunks")
class GetChunks(Resource):
@api.doc(
@@ -141,14 +161,12 @@ class AddChunk(Resource):
metadata["token_count"] = token_count
try:
doc = _resolve_source(doc_id, user)
doc = _resolve_source_for_write(doc_id, user)
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
)
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
chunk_id = store.add_chunk(text, metadata)
@@ -176,14 +194,12 @@ class DeleteChunk(Resource):
chunk_id = request.args.get("chunk_id")
try:
doc = _resolve_source(doc_id, user)
doc = _resolve_source_for_write(doc_id, user)
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
)
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
deleted = store.delete_chunk(chunk_id)
@@ -245,14 +261,12 @@ class UpdateChunk(Resource):
metadata = {}
metadata["token_count"] = token_count
try:
doc = _resolve_source(doc_id, user)
doc = _resolve_source_for_write(doc_id, user)
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
)
return make_response(jsonify({"error": "Source not accessible"}), 403)
try:
store = get_vector_store(str(doc["id"]))
+88 -27
View File
@@ -8,6 +8,10 @@ from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.tasks import reingest_source_task, sync_source
from application.api.user.team_sharing import (
effective_write_owner,
visible_with_access,
)
from application.core.settings import settings
from application.parser.remote.remote_creator import normalize_remote_data
from application.storage.db.repositories.ingest_chunk_progress import (
@@ -61,7 +65,14 @@ class CombinedJson(Resource):
try:
with db_readonly() as conn:
indexes = SourcesRepository(conn).list_for_user(user)
repo = SourcesRepository(conn)
indexes = repo.list_for_user(user)
owned_ids = {str(i["id"]) for i in indexes}
# Sources shared with the caller's teams — surfaced here so they
# are attachable to the member's own agents (direct sharing).
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)
# 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.
@@ -69,22 +80,34 @@ class CombinedJson(Resource):
indexes, key=lambda r: r.get("date") or r.get("created_at") or "",
reverse=True,
)
for index in indexes:
def _source_entry(index, *, ownership="user", team_access=None):
provider = _get_provider_from_remote_data(index.get("remote_data"))
return {
"id": str(index["id"]),
"name": index.get("name"),
"date": index.get("date"),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": index.get("tokens", ""),
"retriever": index.get("retriever", "classic"),
"syncFrequency": index.get("sync_frequency", ""),
"provider": provider,
"is_nested": bool(index.get("directory_structure")),
"type": index.get("type", "file"),
"ownership": ownership,
"team_access": team_access,
}
for index in indexes:
data.append(_source_entry(index))
for index in shared_sources:
data.append(
{
"id": str(index["id"]),
"name": index.get("name"),
"date": index.get("date"),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": index.get("tokens", ""),
"retriever": index.get("retriever", "classic"),
"syncFrequency": index.get("sync_frequency", ""),
"provider": provider,
"is_nested": bool(index.get("directory_structure")),
"type": index.get("type", "file"),
}
_source_entry(
index,
ownership="team",
team_access=team_shared.get(str(index["id"])),
)
)
except Exception as err:
current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True)
@@ -109,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
@@ -125,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"]),
@@ -145,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 = {
@@ -271,12 +307,19 @@ class ManageSync(Resource):
with db_session() as conn:
repo = SourcesRepository(conn)
doc = repo.get_any(source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
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})
except Exception as err:
current_app.logger.error(
f"Error updating sync frequency: {err}", exc_info=True
@@ -305,9 +348,18 @@ class SyncSource(Resource):
if missing_fields:
return missing_fields
source_id = data["source_id"]
# Resolve the owner to run the sync AS: ``user`` when they own the
# source, the real owner when ``user`` holds a team ``editor`` grant
# (re-sync is an editor-allowed write). Vector partitions are keyed by
# 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)
except Exception as err:
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
return make_response(
@@ -315,7 +367,7 @@ class SyncSource(Resource):
)
if not doc:
return make_response(
jsonify({"success": False, "message": "Source not found"}), 404
jsonify({"success": False, "message": "Source not accessible"}), 403
)
source_type = doc.get("type", "")
if source_type and source_type.startswith("connector"):
@@ -337,7 +389,7 @@ class SyncSource(Resource):
task = sync_source.delay(
source_data=source_data,
job_name=doc.get("name", ""),
user=user,
user=owner,
loader=source_type,
sync_frequency=doc.get("sync_frequency", "never"),
retriever=doc.get("retriever", "classic"),
@@ -374,9 +426,18 @@ class ReingestSource(Resource):
if missing_fields:
return missing_fields
source_id = data["source_id"]
# Run the reingest AS the owner: ``user`` when they own the source,
# the real owner when ``user`` holds a team ``editor`` grant (reingest
# is an editor-allowed write). Vector partitions are keyed by 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)
except Exception as err:
current_app.logger.error(
f"Error looking up source: {err}", exc_info=True
@@ -386,7 +447,7 @@ class ReingestSource(Resource):
)
if not doc:
return make_response(
jsonify({"success": False, "message": "Source not found"}), 404
jsonify({"success": False, "message": "Source not accessible"}), 403
)
resolved_source_id = str(doc["id"])
# Drop the stale chunk-progress row so the sources list stops
@@ -404,8 +465,8 @@ class ReingestSource(Resource):
# Scoped key so repeated clicks collapse onto one reingest.
task = reingest_source_task.delay(
source_id=resolved_source_id,
user=user,
idempotency_key=f"reingest-source:{user}:{resolved_source_id}",
user=owner,
idempotency_key=f"reingest-source:{owner}:{resolved_source_id}",
)
except Exception as err:
current_app.logger.error(
+17 -6
View File
@@ -12,6 +12,7 @@ from sqlalchemy import text as sql_text
from application.api import api
from application.api.user.tasks import ingest, ingest_connector_task, ingest_remote
from application.api.user.team_sharing import effective_write_owner
from application.core.settings import settings
from application.storage.db.source_ids import derive_source_id as _derive_source_id
from application.parser.connectors.connector_creator import ConnectorCreator
@@ -541,9 +542,19 @@ class ManageSourceFiles(Resource):
),
400,
)
# Resolve the owner to run the file mutation + reingest AS: ``user``
# when they own the source, the real owner when ``user`` holds a team
# ``editor`` grant (add/remove files is an editor-allowed write). The
# storage layout and vector partition are keyed by source_id
# (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(
@@ -654,7 +665,7 @@ class ManageSourceFiles(Resource):
if map_updated:
with db_session() as conn:
SourcesRepository(conn).update(
resolved_source_id, user,
resolved_source_id, owner,
{"file_name_map": dict(file_name_map)},
)
# Trigger re-ingestion pipeline
@@ -664,7 +675,7 @@ class ManageSourceFiles(Resource):
task = reingest_source_task.apply_async(
kwargs={
"source_id": resolved_source_id,
"user": user,
"user": owner,
"idempotency_key": scoped_key or idempotency_key,
},
task_id=predetermined_task_id,
@@ -766,7 +777,7 @@ class ManageSourceFiles(Resource):
if map_updated and isinstance(file_name_map, dict):
with db_session() as conn:
SourcesRepository(conn).update(
resolved_source_id, user,
resolved_source_id, owner,
{"file_name_map": dict(file_name_map)},
)
# Trigger re-ingestion pipeline
@@ -776,7 +787,7 @@ class ManageSourceFiles(Resource):
task = reingest_source_task.apply_async(
kwargs={
"source_id": resolved_source_id,
"user": user,
"user": owner,
"idempotency_key": scoped_key or idempotency_key,
},
task_id=predetermined_task_id,
@@ -895,7 +906,7 @@ class ManageSourceFiles(Resource):
file_name_map.pop(key, None)
with db_session() as conn:
SourcesRepository(conn).update(
resolved_source_id, user,
resolved_source_id, owner,
{"file_name_map": dict(file_name_map)},
)
@@ -906,7 +917,7 @@ class ManageSourceFiles(Resource):
task = reingest_source_task.apply_async(
kwargs={
"source_id": resolved_source_id,
"user": user,
"user": owner,
"idempotency_key": scoped_key or idempotency_key,
},
task_id=predetermined_task_id,
+109
View File
@@ -0,0 +1,109 @@
"""Team-scoped authorization — the SECOND authz plane beside global RBAC.
Mirrors ``application/api/user/authz.py`` (the global admin/user plane) but for
per-team roles. The two planes never mix: a global ``admin`` is a superuser over
all teams (short-circuit below), but a ``team_admin`` is NOT a global admin.
Key differences from the global resolver, all deliberate:
- Team roles are resolved PER ROUTE (lazy), not eagerly in the app.py chokepoint
— most requests never touch a team route, so we don't pay a team query on the
universal path.
- Team roles are NEVER read from the JWT (same reason as global RBAC: simple_jwt
/ session_jwt are self-mintable).
- ``team_id`` is read ONLY from ``request.view_args`` (the URL path), NEVER the
request body — a body-supplied team_id would be a trivial escalation vector.
- Resolution FAILS CLOSED: a DB error while reading membership denies access
(the inverse of the global resolver, which fails open — here, failing open
would grant team access during an outage).
"""
from __future__ import annotations
import logging
from functools import wraps
from flask import jsonify, make_response, request
from application.api.user.authz import ROLE_ADMIN, has_role
from application.storage.db.repositories.team_members import (
ROLE_TEAM_ADMIN,
ROLE_TEAM_MEMBER,
TeamMembersRepository,
)
from application.storage.db.session import db_readonly
logger = logging.getLogger(__name__)
def team_role_for(token: dict | None, team_id: str | None) -> str | None:
"""Strongest team role the principal holds in ``team_id``, or None.
Fails closed to None on a DB error so a membership-read outage denies team
access rather than granting it.
"""
if not token or not team_id:
return None
sub = token.get("sub")
if not sub:
return None
try:
with db_readonly() as conn:
return TeamMembersRepository(conn).role_for(sub, team_id)
except Exception:
logger.error(
"team_role_for: team_members read failed for sub=%s team=%s",
sub,
team_id,
exc_info=True,
)
return None
def has_team_role(token: dict | None, team_id: str | None, name: str) -> bool:
"""True if the principal satisfies ``name`` for ``team_id``.
A global ``admin`` satisfies any team role (superuser over all teams).
Otherwise ``team_admin`` implies ``team_member`` (admin ⊇ member).
"""
if has_role(token, ROLE_ADMIN):
return True
role = team_role_for(token, team_id)
if role is None:
return False
if name == ROLE_TEAM_MEMBER:
return True
return role == name
def require_team_role(name: str):
"""Decorator factory: 401 when unauthenticated, 403 when lacking ``name``.
Reads ``team_id`` from the route kwargs (URL path) ONLY — never the body.
Fails closed: a missing token, missing team_id, or membership-read error
never passes through.
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
token = getattr(request, "decoded_token", None)
if not token:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
team_id = kwargs.get("team_id")
if not team_id:
return make_response(
jsonify({"success": False, "message": "Team not specified"}), 400
)
if not has_team_role(token, team_id, name):
return make_response(jsonify({"success": False, "message": "Forbidden"}), 403)
return func(*args, **kwargs)
return wrapper
return decorator
team_admin_required = require_team_role(ROLE_TEAM_ADMIN)
team_member_required = require_team_role(ROLE_TEAM_MEMBER)
+124
View File
@@ -0,0 +1,124 @@
"""Cross-cutting helpers for team resource sharing.
Centralises the polymorphic dispatch over the four shareable resource types so
the grant endpoint and the per-entity list/get/update paths share one source of
truth for "does this user own resource R?" and "what can this user see/edit via
their teams?". Every shareable repo exposes the same ``get_any(id, user_id)``
ownership accessor, which is what makes the dispatch uniform.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.team_scope import TeamScopeRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
RESOURCE_TYPES = ("agent", "source", "prompt", "tool")
_REPO_FOR_TYPE = {
"agent": AgentsRepository,
"source": SourcesRepository,
"prompt": PromptsRepository,
"tool": UserToolsRepository,
}
def is_valid_resource_type(resource_type: str) -> bool:
return resource_type in _REPO_FOR_TYPE
def owns_resource(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> bool:
"""True if ``user_id`` is the OWNER of the resource.
Dispatches to the correct repo by ``resource_type`` — this is the guard that
stops a caller registering a grant with a mismatched ``resource_type`` /
``resource_id`` (the polymorphic grant table has no FK to catch it). Owners
only — team-granted ``editor`` access does NOT make you an owner.
"""
repo_cls = _REPO_FOR_TYPE.get(resource_type)
if repo_cls is None:
return False
return repo_cls(conn).get_any(resource_id, user_id) is not None
def team_access_for(
conn: Connection, user_id: str, resource_type: str, resource_id: str
) -> Optional[str]:
"""Strongest team access (``editor``/``viewer``) ``user_id`` has on a resource.
None when no team grant reaches the user (they may still be the owner — that
is a separate dual-key check at the repo).
"""
return TeamScopeRepository(conn).effective_access(user_id, resource_type, resource_id)
def visible_ids_via_teams(
conn: Connection, user_id: str, resource_type: str
) -> set[str]:
"""The id set of ``resource_type`` shared to any team ``user_id`` belongs to."""
return TeamScopeRepository(conn).visible_resource_ids(user_id, resource_type)
def visible_with_access(
conn: Connection, user_id: str, resource_type: str
) -> dict[str, str]:
"""Map ``resource_id -> strongest access`` shared to the user's teams."""
return TeamScopeRepository(conn).visible_with_access(user_id, resource_type)
def effective_write_owner(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> Optional[str]:
"""The owner id to write a resource AS, or None if the caller can't write.
Returns ``user_id`` when the caller owns the resource, or the resource's
real ``owner_id`` when the caller holds a team ``editor`` grant — so callers
can pass the result straight to the existing owner-scoped ``update(id, 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.
"""
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):
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
def can_access(
conn: Connection, resource_type: str, resource_id: str, user_id: str
) -> bool:
"""True if ``user_id`` owns the resource OR has any team grant on it (read).
This is the write-path gate for *referencing* a resource (e.g. attaching a
``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.
"""
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)
+5
View File
@@ -0,0 +1,5 @@
"""Teams API namespace: team CRUD, membership, and resource-sharing grants."""
from .routes import teams_ns
__all__ = ["teams_ns"]
+636
View File
@@ -0,0 +1,636 @@
"""Team management API: CRUD, membership, and resource-sharing grants.
Authorization model (two planes, see ``team_authz.py``):
- Any authenticated user may create a team (self-serve); the creator becomes a
``team_admin`` in the same transaction.
- 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.
``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.
"""
from __future__ import annotations
import logging
import re
import uuid
from flask import jsonify, make_response, request
from flask_restx import Namespace, Resource
from application.api.user.authz import ROLE_ADMIN, has_role
from application.api.user.team_authz import (
has_team_role,
team_admin_required,
team_member_required,
)
from application.api.user.team_sharing import is_valid_resource_type, owns_resource
from application.events.publisher import publish_user_event
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.auth_events import AuthEventsRepository
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.team_members import (
ROLE_TEAM_ADMIN,
ROLE_TEAM_MEMBER,
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
teams_ns = Namespace("teams", description="Team management and resource sharing", path="/api")
_VALID_TEAM_ROLES = (ROLE_TEAM_ADMIN, ROLE_TEAM_MEMBER)
_VALID_ACCESS_LEVELS = ("viewer", "editor")
def _current_user() -> str | None:
token = getattr(request, "decoded_token", None)
return token.get("sub") if isinstance(token, dict) else None
def _audit(conn, actor: str | None, event: str, **metadata) -> None:
"""Append a team management event to the audit trail (best-effort).
Runs inside the action's transaction so the audit row commits atomically
with the change. Never raises into the request path — an audit failure must
not fail the operation.
"""
try:
# SAVEPOINT: a failed audit insert poisons the surrounding txn, so
# nest it — on failure only the audit rolls back, not the action.
with conn.begin_nested():
AuthEventsRepository(conn).insert(
user_id=actor or "unknown",
event=event,
ip=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
metadata={k: v for k, v in metadata.items() if v is not None},
)
except Exception:
logger.warning("team audit insert failed for event=%s", event, exc_info=True)
def _slugify(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
return base or "team"
def _unique_slug(repo: TeamsRepository, base: str) -> str:
"""A slug not yet taken: ``base`` if free, else ``base-<4hex>`` until unique."""
if not repo.slug_exists(base):
return base
for _ in range(8):
candidate = f"{base}-{uuid.uuid4().hex[:4]}"
if not repo.slug_exists(candidate):
return candidate
return f"{base}-{uuid.uuid4().hex}"
# --- Notifications (best-effort, fire-and-forget; never fail the request) ----
# Emitted AFTER the mutation commits, over their own read connection, so a
# name-lookup or Redis hiccup can never roll back the share/membership change.
def _resource_display_name(conn, resource_type: str, resource_id: str) -> str | None:
"""Best-effort human label for a shared resource (ownerless fetch)."""
try:
if resource_type == "agent":
row = AgentsRepository(conn).get_by_id(resource_id)
elif resource_type == "source":
row = SourcesRepository(conn).get_by_id(resource_id)
elif resource_type == "prompt":
row = PromptsRepository(conn).get_for_rendering(resource_id)
elif resource_type == "tool":
row = UserToolsRepository(conn).get_by_id(resource_id)
else:
return None
if not row:
return None
return row.get("custom_name") or row.get("display_name") or row.get("name")
except Exception:
logger.warning("resource name resolve failed (%s)", resource_type, exc_info=True)
return None
def _notify_member_added(
team_id: str, new_user: str | None, role: str, actor: str | None
) -> None:
"""Toast the new member that they were added to a team."""
if not new_user or new_user == actor:
return
try:
with db_readonly() as conn:
team = TeamsRepository(conn).get(team_id)
publish_user_event(
new_user,
"team.member_added",
{
"team_id": str(team_id),
"team_name": team.get("name") if team else None,
"role": role,
"added_by": actor,
},
scope={"kind": "team", "id": str(team_id)},
)
except Exception:
logger.warning("member_added notify failed", exc_info=True)
def _notify_resource_shared(
actor: str | None,
team_id: str,
resource_type: str,
resource_id: str,
access_level: str,
target_user_id: str | None,
) -> None:
"""Toast the recipient(s) of a share.
Per-member share notifies just that member; whole-team share notifies every
current member except the sharer.
"""
try:
with db_readonly() as conn:
resource_name = _resource_display_name(conn, resource_type, resource_id)
team = TeamsRepository(conn).get(team_id)
if target_user_id:
recipients = [target_user_id]
else:
members = TeamMembersRepository(conn).list_members(team_id)
recipients = list({m["user_id"] for m in members})
payload = {
"resource_type": resource_type,
"resource_id": str(resource_id),
"resource_name": resource_name,
"access_level": access_level,
"team_id": str(team_id),
"team_name": team.get("name") if team else None,
"shared_by": actor,
}
for recipient in recipients:
if recipient and recipient != actor:
publish_user_event(
recipient,
"resource.shared",
payload,
scope={"kind": "resource", "id": str(resource_id)},
)
except Exception:
logger.warning("resource_shared notify failed", exc_info=True)
@teams_ns.route("/teams")
class Teams(Resource):
def get(self):
"""List the teams the caller belongs to, each annotated with their role."""
user = _current_user()
if not user:
return {"success": False}, 401
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_for_user(user)
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List teams failed: %s", err, exc_info=True)
return {"success": False}, 400
def post(self):
"""Create a team (self-serve). The creator becomes its first team_admin."""
user = _current_user()
if not user:
return {"success": False}, 401
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return {"success": False, "message": "name required"}, 400
description = data.get("description")
try:
with db_session() as conn:
teams = TeamsRepository(conn)
slug = _unique_slug(teams, _slugify(name))
team = teams.create(name, slug, owner_id=user, description=description)
TeamMembersRepository(conn).add_member(
team["id"], user, role=ROLE_TEAM_ADMIN, source="manual", granted_by=user
)
_audit(conn, user, "team.create", team_id=team["id"], name=name)
team["member_role"] = ROLE_TEAM_ADMIN
return make_response(jsonify({"success": True, "team": team}), 201)
except Exception as err:
logger.error("Create team failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>")
class Team(Resource):
@team_member_required
def get(self, team_id):
"""Team detail with members and the caller's role. Requires membership."""
user = _current_user()
try:
with db_readonly() as conn:
team = TeamsRepository(conn).get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
members = TeamMembersRepository(conn)
team["members"] = members.list_members(team_id)
team["member_role"] = members.role_for(user, team_id)
return make_response(jsonify({"success": True, "team": team}), 200)
except Exception as err:
logger.error("Get team failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_admin_required
def put(self, team_id):
"""Update team name/description. Requires team_admin."""
data = request.get_json(silent=True) or {}
fields = {k: data[k] for k in ("name", "description") if k in data}
if not fields:
return {"success": False, "message": "nothing to update"}, 400
try:
with db_session() as conn:
updated = TeamsRepository(conn).update(team_id, fields)
return make_response(jsonify({"success": bool(updated)}), 200)
except Exception as err:
logger.error("Update team failed: %s", err, exc_info=True)
return {"success": False}, 400
def delete(self, team_id):
"""Delete the team. Owner-only (a global admin overrides)."""
user = _current_user()
if not user:
return {"success": False}, 401
try:
with db_session() as conn:
team = TeamsRepository(conn).get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
token = getattr(request, "decoded_token", None)
if team["owner_id"] != user and not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Only the team owner can delete"}, 403
TeamsRepository(conn).delete(team_id)
_audit(conn, user, "team.delete", team_id=team_id)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Delete team failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/members")
class TeamMembers(Resource):
@team_member_required
def get(self, team_id):
"""List members. Requires membership."""
try:
with db_readonly() as conn:
members = TeamMembersRepository(conn).list_members(team_id)
return make_response(jsonify({"success": True, "members": members}), 200)
except Exception as err:
logger.error("List members failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_admin_required
def post(self, team_id):
"""Add a member by email (preferred) or raw user_id. Requires team_admin."""
data = request.get_json(silent=True) or {}
new_user = (data.get("user_id") or "").strip()
email = (data.get("email") or "").strip()
role = data.get("role", ROLE_TEAM_MEMBER)
if role not in _VALID_TEAM_ROLES:
return {"success": False, "message": "invalid role"}, 400
if not new_user and not email:
return {"success": False, "message": "email or user_id required"}, 400
try:
with db_session() as conn:
# Resolve an email to its sub (the user must have logged in at
# least once for their email to be on file).
if not new_user and email:
user_row = UsersRepository(conn).find_by_email(email)
if not user_row:
return {
"success": False,
"message": "No user found with that email (they must sign in once first)",
}, 404
new_user = user_row["user_id"]
TeamMembersRepository(conn).set_manual_role(
team_id, new_user, role, granted_by=_current_user()
)
_audit(
conn,
_current_user(),
"team.member_add",
team_id=team_id,
target_user=new_user,
role=role,
)
# Post-commit, best-effort: tell the new member they were added.
_notify_member_added(team_id, new_user, role, _current_user())
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Add member failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/members/<string:member_id>")
class TeamMember(Resource):
@team_admin_required
def put(self, team_id, member_id):
"""Change a member's role. Requires team_admin. Guards the last admin."""
data = request.get_json(silent=True) or {}
role = data.get("role")
if role not in _VALID_TEAM_ROLES:
return {"success": False, "message": "invalid role"}, 400
try:
with db_session() as conn:
members = TeamMembersRepository(conn)
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
members, team_id, member_id
):
return {
"success": False,
"message": "Cannot demote the last team admin",
}, 409
members.set_manual_role(team_id, member_id, role, granted_by=_current_user())
_audit(
conn,
_current_user(),
"team.member_role",
team_id=team_id,
target_user=member_id,
role=role,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Update member role failed: %s", err, exc_info=True)
return {"success": False}, 400
@team_member_required
def delete(self, team_id, member_id):
"""Remove a member. team_admin removes anyone; a member may remove self
(leave). Guards the last admin."""
user = _current_user()
token = getattr(request, "decoded_token", None)
is_self = member_id == user
if not is_self and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_session() as conn:
members = TeamMembersRepository(conn)
if self._would_orphan_admins(members, team_id, member_id):
return {
"success": False,
"message": "Cannot remove the last team admin",
}, 409
members.remove_member(team_id, member_id)
_audit(
conn,
user,
"team.member_remove",
team_id=team_id,
target_user=member_id,
self_leave=is_self,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Remove member failed: %s", err, exc_info=True)
return {"success": False}, 400
@staticmethod
def _would_orphan_admins(
members: TeamMembersRepository, team_id: str, member_id: str
) -> bool:
"""True if removing/demoting ``member_id`` leaves the team with no admin.
Locks the admin rows (FOR UPDATE) so concurrent demote/remove calls
serialize — preventing two simultaneous removals of distinct admins from
both passing the guard and orphaning the team.
"""
admins = members.lock_admins(team_id)
if member_id not in admins:
return False
return len(admins) <= 1
@teams_ns.route("/teams/<string:team_id>/grants")
class TeamGrants(Resource):
@team_member_required
def get(self, team_id):
"""List resources shared with this team. Requires membership."""
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)
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)."""
user = _current_user()
data = request.get_json(silent=True) or {}
resource_type = data.get("resource_type")
resource_id = data.get("resource_id")
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)
):
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
# 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
):
return {"success": False, "message": "Target is not a team member"}, 400
grant = TeamResourceGrantsRepository(conn).grant(
team_id,
resource_type,
resource_id,
owner_id=user,
granted_by=user,
access_level=access_level,
target_user_id=target_user_id,
)
_audit(
conn,
user,
"team.share",
team_id=team_id,
resource_type=resource_type,
resource_id=resource_id,
access_level=access_level,
target_user_id=target_user_id,
)
# Post-commit, best-effort: tell the recipient(s) it was shared.
_notify_resource_shared(
user, team_id, resource_type, resource_id, access_level, target_user_id
)
return make_response(jsonify({"success": True, "grant": grant}), 201)
except Exception as err:
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.
Identifiers come from query params (some proxies strip DELETE bodies),
with a JSON-body fallback for older clients.
"""
user = _current_user()
token = getattr(request, "decoded_token", None)
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")
# Which grant to remove: whole-team (None) or a specific member's.
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)
):
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
revoked = TeamResourceGrantsRepository(conn).revoke(
team_id, resource_type, resource_id, target_user_id=target_user_id
)
if revoked:
_audit(
conn,
user,
"team.unshare",
team_id=team_id,
resource_type=resource_type,
resource_id=resource_id,
target_user_id=target_user_id,
)
return make_response(jsonify({"success": bool(revoked)}), 200)
except Exception as err:
logger.error("Unshare resource failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/teams/<string:team_id>/transfer_owner")
class TeamOwnerTransfer(Resource):
def post(self, team_id):
"""Transfer team ownership. Owner-only (global admin overrides).
The new owner must already be a member; they are promoted to team_admin.
"""
user = _current_user()
if not user:
return {"success": False}, 401
data = request.get_json(silent=True) or {}
new_owner = (data.get("user_id") or "").strip()
if not new_owner:
return {"success": False, "message": "user_id required"}, 400
token = getattr(request, "decoded_token", None)
try:
with db_session() as conn:
teams = TeamsRepository(conn)
team = teams.get(team_id)
if not team:
return {"success": False, "message": "Not found"}, 404
if team["owner_id"] != user and not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Only the team owner can transfer"}, 403
members = TeamMembersRepository(conn)
if not members.is_member(new_owner, team_id):
return {"success": False, "message": "New owner must be a member"}, 400
members.set_manual_role(team_id, new_owner, ROLE_TEAM_ADMIN, granted_by=user)
teams.reassign_owner(team_id, new_owner)
_audit(
conn,
user,
"team.transfer_owner",
team_id=team_id,
new_owner=new_owner,
)
return make_response(jsonify({"success": True}), 200)
except Exception as err:
logger.error("Transfer owner failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/resource_shares")
class ResourceShares(Resource):
def get(self):
"""List the teams a resource the caller OWNS is shared with.
Powers the share dialog (show current shares + unshare). Owner-only so a
non-owner can't enumerate a resource's sharing graph.
"""
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)
):
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
shares = TeamResourceGrantsRepository(conn).list_for_resource(
resource_type, resource_id
)
return make_response(jsonify({"success": True, "shares": shares}), 200)
except Exception as err:
logger.error("List resource shares failed: %s", err, exc_info=True)
return {"success": False}, 400
@teams_ns.route("/admin/teams")
class AllTeams(Resource):
method_decorators = []
def get(self):
"""Global-admin oversight: every team with member counts."""
token = getattr(request, "decoded_token", None)
if not token:
return {"success": False}, 401
if not has_role(token, ROLE_ADMIN):
return {"success": False, "message": "Forbidden"}, 403
try:
with db_readonly() as conn:
teams = TeamsRepository(conn).list_all()
return make_response(jsonify({"success": True, "teams": teams}), 200)
except Exception as err:
logger.error("List all teams failed: %s", err, exc_info=True)
return {"success": False}, 400
+35 -14
View File
@@ -15,6 +15,7 @@ from application.agents.default_tools import (
from application.agents.tools.spec_parser import parse_spec
from application.agents.tools.tool_manager import ToolManager
from application.api import api
from application.api.user.team_sharing import effective_write_owner, visible_with_access
from application.core.url_validation import SSRFError, validate_url
from application.security.encryption import decrypt_credentials, encrypt_credentials
from application.storage.db.repositories.notes import NotesRepository
@@ -217,27 +218,43 @@ class GetTools(Resource):
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
with db_readonly() as conn:
rows = UserToolsRepository(conn).list_for_user(user)
tools_repo = UserToolsRepository(conn)
rows = tools_repo.list_for_user(user)
user_doc = UsersRepository(conn).get(user)
owned_ids = {str(r["id"]) for r in rows}
# Tools shared with the caller's teams. Secrets are stripped
# unconditionally below — a grantee never sees the owner's
# credentials (they only run with the owner's creds server-side).
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)
user_tools = []
for row in rows:
tool_copy = _row_to_api(row)
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
tool_copy = _row_to_api(row)
config_req = tool_copy.get("configRequirements", {})
if not config_req:
tool_instance = tool_manager.tools.get(tool_copy.get("name"))
if tool_instance:
config_req = tool_instance.get_config_requirements()
tool_copy["configRequirements"] = config_req
has_secrets = any(
spec.get("secret") for spec in config_req.values()
) if config_req else False
if has_secrets and "encrypted_credentials" in tool_copy.get("config", {}):
if (has_secrets or force_strip_secret) and "encrypted_credentials" in tool_copy.get(
"config", {}
):
tool_copy["config"]["has_encrypted_credentials"] = True
tool_copy["config"].pop("encrypted_credentials", None)
tool_copy["ownership"] = ownership
return tool_copy
user_tools.append(tool_copy)
for row in rows:
user_tools.append(_shape_tool(row))
for row in shared_rows:
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
shaped["team_access"] = team_shared.get(str(row["id"]))
user_tools.append(shaped)
# ``scheduler`` is dual-registered (default chat tool + agent-
# selectable builtin) and resolves to the same synthetic uuid5 id.
@@ -649,14 +666,18 @@ class UpdateToolActions(Resource):
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.update(
str(tool_doc["id"]), user, {"actions": data["actions"]},
)
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"]})
except Exception as err:
current_app.logger.error(
f"Error updating tool actions: {err}", exc_info=True
+1 -1
View File
@@ -4,4 +4,4 @@ models:
- id: docsgpt-local
display_name: DocsGPT Model
description: Local model
supports_tools: false
supports_tools: true
+94
View File
@@ -45,6 +45,9 @@ users_table = Table(
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
Column("user_id", Text, nullable=False, unique=True),
# Populated from the OIDC email claim at login (migration 0023); used to add
# a team member by email instead of raw sub. Nullable / backfilled on login.
Column("email", Text),
Column(
"agent_preferences",
JSONB,
@@ -86,6 +89,97 @@ user_roles_table = Table(
CheckConstraint("source IN ('manual', 'oidc_group')", name="user_roles_source_check"),
)
# --- Teams: multi-team membership, team-scoped roles, resource sharing -------
# See migration 0021. Three additive tables; no existing table is altered.
# A team. ``owner_id`` is the creator's auth ``sub`` and the deletion anchor —
# intentionally no FK/trigger (like ``user_roles``) so user deletion isn't
# blocked by an ON DELETE RESTRICT.
teams_table = Table(
"teams",
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
Column("name", Text, nullable=False),
Column("slug", CITEXT, nullable=False, unique=True),
Column("description", Text),
Column("owner_id", Text, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
Index("teams_owner_idx", teams_table.c.owner_id)
# Membership + team-scoped role grant, field-for-field on ``user_roles``.
# ``(team_id, user_id, role, source)`` so manual and IdP-derived grants coexist
# and revoke independently. ``user_id`` is the auth ``sub`` (no FK/trigger).
team_members_table = Table(
"team_members",
metadata,
Column("team_id", UUID(as_uuid=True), nullable=False),
Column("user_id", Text, nullable=False),
Column("role", Text, nullable=False),
Column("source", Text, nullable=False, server_default="manual"),
Column("granted_by", Text),
Column("granted_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("team_id", "user_id", "role", "source"),
CheckConstraint("role IN ('team_admin', 'team_member')", name="team_members_role_check"),
CheckConstraint(
"source IN ('manual', 'oidc_group', 'scim')", name="team_members_source_check"
),
)
Index("team_members_user_idx", team_members_table.c.user_id)
# One polymorphic share table for all four shareable resource types. Sharing is
# additive visibility, never ownership transfer. ``owner_id`` is denormalised
# owner-at-share-time so visibility queries skip the resource-table join.
# ``resource_id`` has no cross-table FK (polymorphic); dangling rows are scrubbed
# by AFTER DELETE triggers on each resource table (see migration 0021).
team_resource_grants_table = Table(
"team_resource_grants",
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
Column("team_id", UUID(as_uuid=True), nullable=False),
Column("resource_type", Text, nullable=False),
Column("resource_id", UUID(as_uuid=True), nullable=False),
Column("owner_id", Text, nullable=False),
Column("access_level", Text, nullable=False, server_default="viewer"),
# NULL = shared with the whole team; a sub = shared with that one member
# (who must also be a team member). See migration 0022.
Column("target_user_id", Text),
Column("granted_by", Text, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
CheckConstraint(
"resource_type IN ('agent', 'source', 'prompt', 'tool')",
name="team_resource_grants_type_check",
),
CheckConstraint(
"access_level IN ('viewer', 'editor')", name="team_resource_grants_access_check"
),
)
# Functional unique dedup: a whole-team grant (target NULL → '') and each
# per-member grant are distinct. Mirrors migration 0022.
Index(
"team_resource_grants_dedup_uidx",
team_resource_grants_table.c.team_id,
team_resource_grants_table.c.resource_type,
team_resource_grants_table.c.resource_id,
func.coalesce(team_resource_grants_table.c.target_user_id, ""),
unique=True,
)
Index(
"team_resource_grants_team_type_idx",
team_resource_grants_table.c.team_id,
team_resource_grants_table.c.resource_type,
)
Index(
"team_resource_grants_resource_idx",
team_resource_grants_table.c.resource_type,
team_resource_grants_table.c.resource_id,
)
prompts_table = Table(
"prompts",
metadata,
+86 -1
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection, func, text
from sqlalchemy import Connection, cast, func, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from application.storage.db.base_repository import looks_like_uuid, row_to_dict
@@ -160,6 +160,91 @@ class AgentsRepository:
)
return [row_to_dict(r) for r in result.fetchall()]
def get_by_id(self, agent_id: str) -> Optional[dict]:
"""Fetch an agent by id with NO ownership scoping.
Used ONLY after a team-grant authorization check has already confirmed
the caller may see this agent (see team sharing). Never call this on a
raw user-supplied id without that check — it bypasses the dual-key guard.
"""
if not looks_like_uuid(agent_id):
return None
result = self._conn.execute(
text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"),
{"id": agent_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_by_ids(self, agent_ids) -> list[dict]:
"""Fetch agents whose id is in ``agent_ids`` (team-shared listing path)."""
ids = [str(a) for a in agent_ids if looks_like_uuid(str(a))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM agents WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def update_by_id(
self, agent_id: str, fields: dict, expected_updated_at=None
) -> Optional[bool]:
"""Update an agent by id WITHOUT user scoping (team ``editor`` write path).
The caller MUST have verified the principal holds an ``editor`` grant
before calling. When ``expected_updated_at`` is given, the update is
applied only if the row's ``updated_at`` still matches (optimistic lock);
returns None on a version mismatch so the route can answer 409.
"""
# Reuse the owner-path column allowlist + coercion by delegating, but
# against the id-only predicate. We rebuild the same filtered values.
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
"limited_request_mode", "request_limit",
"allow_system_prompt_override",
"shared", "shared_token", "shared_metadata",
"incoming_webhook_token", "last_used_at",
}
filtered = {k: v for k, v in fields.items() if k in allowed}
if not filtered:
return False
values: dict = {}
for col, val in filtered.items():
if col in ("tools", "json_schema", "models", "shared_metadata"):
values[col] = val
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
values[col] = str(val) if val else None
elif col == "extra_source_ids":
values[col] = [str(x) for x in val] if val else []
elif col in (
"limited_token_mode", "limited_request_mode",
"shared", "allow_system_prompt_override",
):
values[col] = bool(val)
else:
values[col] = self._normalize_unique_text(col, val)
values["updated_at"] = func.now()
t = agents_table
stmt = t.update().where(t.c.id == agent_id)
if expected_updated_at is not None:
# Cast the (string/datetime) bind to timestamptz so PG compares the
# optimistic-lock token against the column type, not text.
stmt = stmt.where(t.c.updated_at == cast(expected_updated_at, t.c.updated_at.type))
stmt = stmt.values(**values)
result = self._conn.execute(stmt)
if result.rowcount > 0:
return True
# Distinguish a stale optimistic-lock write (row exists) from a missing row.
if expected_updated_at is not None and self.get_by_id(str(agent_id)) is not None:
return None
return False
def list_templates(self) -> list[dict]:
# Template/system agents are seeded under ``__system__`` (migration
# 0001 + the seeder); the legacy ``'system'`` value is kept in the
@@ -105,6 +105,40 @@ class PromptsRepository:
)
return [row_to_dict(r) for r in result.fetchall()]
def list_by_ids(self, prompt_ids) -> list[dict]:
"""Fetch prompts whose id is in ``prompt_ids`` (team-shared listing path)."""
ids = [str(p) for p in prompt_ids if looks_like_uuid(str(p))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM prompts WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def update_by_id(
self, prompt_id: str, name: str, content: str, expected_updated_at=None
) -> Optional[bool]:
"""Update a prompt by id WITHOUT user scoping (team ``editor`` write path).
Caller must have verified an ``editor`` grant first. Returns None on an
optimistic-lock version mismatch so the route can answer 409.
"""
sql = (
"UPDATE prompts SET name = :name, content = :content, updated_at = now() "
"WHERE id = CAST(:id AS uuid)"
)
params: dict = {"id": prompt_id, "name": name, "content": content}
if expected_updated_at is not None:
sql += " AND updated_at = CAST(:expected AS timestamptz)"
params["expected"] = expected_updated_at
result = self._conn.execute(text(sql), params)
if result.rowcount > 0:
return True
if expected_updated_at is not None and self.get_for_rendering(str(prompt_id)) is not None:
return None
return False
def update(self, prompt_id: str, user_id: str, name: str, content: str) -> None:
self._conn.execute(
text(
+63 -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.
@@ -174,6 +206,32 @@ class SourcesRepository:
return row
return self.get_by_legacy_id(source_id, user_id)
def get_by_id(self, source_id: str) -> Optional[dict]:
"""Fetch a source by id with NO ownership scoping.
Used ONLY after a team-grant authorization check (team sharing). Never
call on a raw user-supplied id without that check.
"""
if not looks_like_uuid(source_id):
return None
result = self._conn.execute(
text("SELECT * FROM sources WHERE id = CAST(:id AS uuid)"),
{"id": source_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_by_ids(self, source_ids) -> list[dict]:
"""Fetch sources whose id is in ``source_ids`` (team-shared listing path)."""
ids = [str(s) for s in source_ids if looks_like_uuid(str(s))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM sources WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at DESC"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def find_by_name(self, user_id: str, name: str) -> Optional[dict]:
"""Return a user's source whose name matches ``name`` (case-insensitive).
@@ -202,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.
@@ -243,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(
@@ -272,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.
@@ -289,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(
@@ -0,0 +1,197 @@
"""Repository for the ``team_members`` table (membership + team-scoped roles).
Field-for-field on ``user_roles``: grants are keyed by
``(team_id, user_id, role, source)`` so a manual grant and a future IdP-derived
grant for the same user coexist and revoke independently. ``user_id`` is the
auth ``sub``. The ``team_member`` role is NOT implicit — every member has at
least one row (unlike the global ``user`` role). All methods take a
``Connection`` and do not manage their own transactions.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection, text
from application.storage.db.base_repository import row_to_dict
ROLE_TEAM_ADMIN = "team_admin"
ROLE_TEAM_MEMBER = "team_member"
class TeamMembersRepository:
"""Team membership grants keyed by the auth ``sub`` (``user_id``)."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def role_for(self, user_id: str, team_id: str) -> Optional[str]:
"""Strongest role ``user_id`` holds in ``team_id`` (any source), or None.
``team_admin`` outranks ``team_member``. Returns None when the user is
not a member — the caller treats that as "no team access".
"""
if not user_id or not team_id:
return None
result = self._conn.execute(
text(
"""
SELECT CASE WHEN bool_or(role = 'team_admin') THEN 'team_admin'
ELSE 'team_member' END AS role
FROM team_members
WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id
HAVING count(*) > 0
"""
),
{"team_id": team_id, "user_id": user_id},
)
row = result.fetchone()
return row[0] if row is not None else None
def is_member(self, user_id: str, team_id: str) -> bool:
return self.role_for(user_id, team_id) is not None
def list_team_ids_for(self, user_id: str) -> list[str]:
"""The distinct team ids ``user_id`` belongs to (hot authz reverse lookup)."""
if not user_id:
return []
result = self._conn.execute(
text("SELECT DISTINCT team_id FROM team_members WHERE user_id = :user_id"),
{"user_id": user_id},
)
return [str(row[0]) for row in result.fetchall()]
def list_members(self, team_id: str) -> list[dict]:
"""One row per (user, role, source) in the team, ordered by grant time.
Left-joins ``users`` to surface each member's ``email`` (when on file)
so the UI can show a human-readable identity instead of the raw sub.
"""
result = self._conn.execute(
text(
"""
SELECT m.user_id, m.role, m.source, m.granted_by, m.granted_at,
u.email
FROM team_members m
LEFT JOIN users u ON u.user_id = m.user_id
WHERE m.team_id = CAST(:team_id AS uuid)
ORDER BY m.granted_at
"""
),
{"team_id": team_id},
)
return [row_to_dict(r) for r in result.fetchall()]
def count_admins(self, team_id: str) -> int:
"""Distinct users holding ``team_admin`` — for the last-admin guard."""
result = self._conn.execute(
text(
"""
SELECT count(DISTINCT user_id) FROM team_members
WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin'
"""
),
{"team_id": team_id},
)
return int(result.scalar() or 0)
def lock_admins(self, team_id: str) -> list[str]:
"""Lock the team's ``team_admin`` rows and return the distinct admins.
``SELECT ... FOR UPDATE`` inside the caller's write transaction
serializes concurrent demote/remove operations on the team's admins, so
the last-admin guard can't be raced into orphaning the team (two
concurrent removes both seeing count=2). ``DISTINCT`` is incompatible
with ``FOR UPDATE``, so de-dup in Python (a user may hold team_admin via
multiple sources).
"""
result = self._conn.execute(
text(
"""
SELECT user_id FROM team_members
WHERE team_id = CAST(:team_id AS uuid) AND role = 'team_admin'
FOR UPDATE
"""
),
{"team_id": team_id},
)
return sorted({str(row[0]) for row in result.fetchall()})
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def add_member(
self,
team_id: str,
user_id: str,
role: str = ROLE_TEAM_MEMBER,
source: str = "manual",
granted_by: Optional[str] = None,
) -> bool:
"""Idempotently add a membership grant. Returns True if a row was inserted."""
result = self._conn.execute(
text(
"""
INSERT INTO team_members (team_id, user_id, role, source, granted_by)
VALUES (CAST(:team_id AS uuid), :user_id, :role, :source, :granted_by)
ON CONFLICT (team_id, user_id, role, source) DO NOTHING
"""
),
{
"team_id": team_id,
"user_id": user_id,
"role": role,
"source": source,
"granted_by": granted_by,
},
)
return bool(result.rowcount)
def set_manual_role(
self,
team_id: str,
user_id: str,
role: str,
granted_by: Optional[str] = None,
) -> None:
"""Set the user's ``manual`` role to exactly ``role`` (promote/demote).
Deletes any other manual role row for the user in this team, then adds
the target — keeping the single-manual-role invariant. IdP-sourced rows
are untouched.
"""
self._conn.execute(
text(
"""
DELETE FROM team_members
WHERE team_id = CAST(:team_id AS uuid)
AND user_id = :user_id AND source = 'manual' AND role <> :role
"""
),
{"team_id": team_id, "user_id": user_id, "role": role},
)
self.add_member(team_id, user_id, role=role, source="manual", granted_by=granted_by)
def remove_member(
self,
team_id: str,
user_id: str,
source: Optional[str] = None,
) -> bool:
"""Remove the user's membership rows from the team.
When ``source`` is given only that source's rows are removed (so an IdP
sync can't wipe a manual grant); otherwise every role/source row for the
user in the team is removed. Returns True if anything was deleted.
"""
sql = "DELETE FROM team_members WHERE team_id = CAST(:team_id AS uuid) AND user_id = :user_id"
params: dict = {"team_id": team_id, "user_id": user_id}
if source is not None:
sql += " AND source = :source"
params["source"] = source
result = self._conn.execute(text(sql), params)
return bool(result.rowcount)
@@ -0,0 +1,167 @@
"""Repository for the ``team_resource_grants`` table (resource sharing).
One polymorphic table for all four shareable resource types
(``agent``/``source``/``prompt``/``tool``). A grant makes a resource visible to
a team as additive visibility — never ownership transfer; the resource's
``user_id`` owner is unchanged. ``owner_id`` is denormalised owner-at-share-time.
Per-user effective visibility/access (the JOIN to ``team_members``) lives in
[[team_scope]]. All methods take a ``Connection``.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection, text
from application.storage.db.base_repository import row_to_dict
VALID_RESOURCE_TYPES = ("agent", "source", "prompt", "tool")
VALID_ACCESS_LEVELS = ("viewer", "editor")
class TeamResourceGrantsRepository:
"""Share grants keyed by ``(team_id, resource_type, resource_id)``."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def list_for_team(
self, team_id: str, resource_type: Optional[str] = None
) -> list[dict]:
"""All grants for a team, optionally filtered to one resource type."""
sql = "SELECT * FROM team_resource_grants WHERE team_id = CAST(:team_id AS uuid)"
params: dict = {"team_id": team_id}
if resource_type is not None:
sql += " AND resource_type = :resource_type"
params["resource_type"] = resource_type
sql += " ORDER BY created_at DESC"
result = self._conn.execute(text(sql), params)
return [row_to_dict(r) for r in result.fetchall()]
def list_for_resource(self, resource_type: str, resource_id: str) -> list[dict]:
"""Which teams a resource is shared with (the owner's share-management view)."""
result = self._conn.execute(
text(
"""
SELECT g.*, t.name AS team_name, t.slug AS team_slug
FROM team_resource_grants g
JOIN teams t ON t.id = g.team_id
WHERE g.resource_type = :resource_type
AND g.resource_id = CAST(:resource_id AS uuid)
ORDER BY t.name
"""
),
{"resource_type": resource_type, "resource_id": resource_id},
)
return [row_to_dict(r) for r in result.fetchall()]
def get(
self,
team_id: str,
resource_type: str,
resource_id: str,
target_user_id: Optional[str] = None,
) -> Optional[dict]:
"""Fetch one grant. ``target_user_id`` None matches the whole-team grant;
a sub matches that member's grant (NULL/'' coalesced so both compare)."""
result = self._conn.execute(
text(
"""
SELECT * FROM team_resource_grants
WHERE team_id = CAST(:team_id AS uuid)
AND resource_type = :resource_type
AND resource_id = CAST(:resource_id AS uuid)
AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '')
"""
),
{
"team_id": team_id,
"resource_type": resource_type,
"resource_id": resource_id,
"target_user_id": target_user_id,
},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def grant(
self,
team_id: str,
resource_type: str,
resource_id: str,
owner_id: str,
granted_by: str,
access_level: str = "viewer",
target_user_id: Optional[str] = None,
) -> dict:
"""Share a resource with a team (or one member), upserting the access level.
``target_user_id`` None shares with the whole team; a sub shares with
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.
"""
result = self._conn.execute(
text(
"""
INSERT INTO team_resource_grants
(team_id, resource_type, resource_id, owner_id, access_level,
granted_by, target_user_id)
VALUES
(CAST(:team_id AS uuid), :resource_type, CAST(:resource_id AS uuid),
:owner_id, :access_level, :granted_by, :target_user_id)
ON CONFLICT (team_id, resource_type, resource_id, COALESCE(target_user_id, ''))
DO UPDATE SET access_level = EXCLUDED.access_level,
granted_by = EXCLUDED.granted_by
RETURNING *
"""
),
{
"team_id": team_id,
"resource_type": resource_type,
"resource_id": resource_id,
"owner_id": owner_id,
"access_level": access_level,
"granted_by": granted_by,
"target_user_id": target_user_id,
},
)
return row_to_dict(result.fetchone())
def revoke(
self,
team_id: str,
resource_type: str,
resource_id: str,
target_user_id: Optional[str] = None,
) -> bool:
"""Unshare. ``target_user_id`` None removes the whole-team grant; a sub
removes that member's grant. Access is lost on the next request."""
result = self._conn.execute(
text(
"""
DELETE FROM team_resource_grants
WHERE team_id = CAST(:team_id AS uuid)
AND resource_type = :resource_type
AND resource_id = CAST(:resource_id AS uuid)
AND COALESCE(target_user_id, '') = COALESCE(:target_user_id, '')
"""
),
{
"team_id": team_id,
"resource_type": resource_type,
"resource_id": resource_id,
"target_user_id": target_user_id,
},
)
return result.rowcount > 0
@@ -0,0 +1,113 @@
"""Per-user team visibility resolution.
This is the read-side authorization helper for team sharing: it answers "which
resources of type X can ``user_id`` see via their team memberships?" and "what
access level does ``user_id`` effectively have on resource R?". Membership is
JOINed LIVE against ``team_members`` on every call — never cached, never read
from the JWT — so revoking a membership or a grant drops access on the very next
request (closes the revocation race by construction). All methods take a
``Connection``.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection, text
from application.storage.db.base_repository import looks_like_uuid
class TeamScopeRepository:
"""Live JOIN of ``team_members`` × ``team_resource_grants`` for one user."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def visible_resource_ids(self, user_id: str, resource_type: str) -> set[str]:
"""The set of ``resource_type`` ids shared to any team ``user_id`` is in.
Returned as canonical-string UUIDs so callers can build an
``id = ANY(:ids)`` union clause against the resource table.
"""
if not user_id:
return set()
result = self._conn.execute(
text(
"""
SELECT DISTINCT g.resource_id
FROM team_resource_grants g
JOIN team_members m ON m.team_id = g.team_id
WHERE m.user_id = :user_id AND g.resource_type = :resource_type
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
"""
),
{"user_id": user_id, "resource_type": resource_type},
)
return {str(row[0]) for row in result.fetchall()}
def visible_with_access(self, user_id: str, resource_type: str) -> dict[str, str]:
"""Map of ``resource_id -> strongest access`` shared to the user's teams.
One query for the whole list path — ``editor`` outranks ``viewer`` per
resource. Keys are canonical-string UUIDs.
"""
if not user_id:
return {}
result = self._conn.execute(
text(
"""
SELECT g.resource_id,
CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor'
ELSE 'viewer' END AS access_level
FROM team_resource_grants g
JOIN team_members m ON m.team_id = g.team_id
WHERE m.user_id = :user_id AND g.resource_type = :resource_type
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
GROUP BY g.resource_id
"""
),
{"user_id": user_id, "resource_type": resource_type},
)
return {str(row[0]): row[1] for row in result.fetchall()}
def effective_access(
self, user_id: str, resource_type: str, resource_id: str
) -> Optional[str]:
"""Strongest access ``user_id`` has on a resource via teams, or None.
``editor`` outranks ``viewer``. None means no team grant reaches the user
(they may still be the owner — that is checked separately by the repo's
dual-key path). Used for both read authz (any value → visible) and write
authz (``editor`` → may edit).
"""
# Grant rows only ever hold canonical UUIDs; a non-UUID id (e.g. a
# legacy Mongo ObjectId reaching the single-fetch fallback) can never
# match a grant, and CASTing it would raise and poison the txn — so
# short-circuit to None instead.
if not user_id or not looks_like_uuid(resource_id):
return None
result = self._conn.execute(
text(
"""
SELECT CASE WHEN bool_or(g.access_level = 'editor') THEN 'editor'
ELSE 'viewer' END AS access_level
FROM team_resource_grants g
JOIN team_members m ON m.team_id = g.team_id
WHERE m.user_id = :user_id
AND g.resource_type = :resource_type
AND g.resource_id = CAST(:resource_id AS uuid)
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
HAVING count(*) > 0
"""
),
{"user_id": user_id, "resource_type": resource_type, "resource_id": resource_id},
)
row = result.fetchone()
return row[0] if row is not None else None
def can_read(self, user_id: str, resource_type: str, resource_id: str) -> bool:
return self.effective_access(user_id, resource_type, resource_id) is not None
def can_write(self, user_id: str, resource_type: str, resource_id: str) -> bool:
return self.effective_access(user_id, resource_type, resource_id) == "editor"
@@ -0,0 +1,158 @@
"""Repository for the ``teams`` table.
A team is owned by ``owner_id`` (the creator's auth ``sub``), which is the
durable "who can delete the team" anchor. Membership and role grants live in
``team_members`` ([[team_members]]); shared resources in ``team_resource_grants``.
All methods take a ``Connection`` and do not manage their own transactions.
"""
from __future__ import annotations
from typing import Optional
from sqlalchemy import Connection, text
from application.storage.db.base_repository import row_to_dict
class TeamsRepository:
"""CRUD for teams keyed by the team UUID."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def get(self, team_id: str) -> Optional[dict]:
result = self._conn.execute(
text("SELECT * FROM teams WHERE id = CAST(:id AS uuid)"),
{"id": team_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def get_by_slug(self, slug: str) -> Optional[dict]:
if not slug:
return None
result = self._conn.execute(
text("SELECT * FROM teams WHERE slug = :slug"),
{"slug": slug},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def slug_exists(self, slug: str) -> bool:
result = self._conn.execute(
text("SELECT 1 FROM teams WHERE slug = :slug"),
{"slug": slug},
)
return result.fetchone() is not None
def list_for_user(self, user_id: str) -> list[dict]:
"""Teams ``user_id`` belongs to, each annotated with the member's role.
``role`` is the strongest grant (team_admin > team_member). Ordered by
team name for stable UI listing.
"""
if not user_id:
return []
result = self._conn.execute(
text(
"""
SELECT t.*,
CASE WHEN bool_or(m.role = 'team_admin') THEN 'team_admin'
ELSE 'team_member' END AS member_role,
(SELECT count(DISTINCT mm.user_id) FROM team_members mm
WHERE mm.team_id = t.id) AS member_count,
(SELECT count(DISTINCT g.resource_id)
FROM team_resource_grants g
WHERE g.team_id = t.id) AS shared_count
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = :user_id
GROUP BY t.id
ORDER BY t.name
"""
),
{"user_id": user_id},
)
return [row_to_dict(r) for r in result.fetchall()]
def list_all(self) -> list[dict]:
"""All teams with member counts — global-admin oversight only."""
result = self._conn.execute(
text(
"""
SELECT t.*,
(SELECT count(DISTINCT user_id) FROM team_members m
WHERE m.team_id = t.id) AS member_count
FROM teams t
ORDER BY t.created_at DESC
"""
)
)
return [row_to_dict(r) for r in result.fetchall()]
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def create(
self,
name: str,
slug: str,
owner_id: str,
description: Optional[str] = None,
) -> dict:
result = self._conn.execute(
text(
"""
INSERT INTO teams (name, slug, owner_id, description)
VALUES (:name, :slug, :owner_id, :description)
RETURNING *
"""
),
{"name": name, "slug": slug, "owner_id": owner_id, "description": description},
)
return row_to_dict(result.fetchone())
# Each updatable column maps to a CONSTANT SQL fragment. The SET clause is
# assembled only from these literals (selected by whitelisted keys), so no
# user-controlled string ever reaches the query text — values travel as
# bound parameters. Keeps the query injection-free by construction.
_UPDATABLE_COLUMNS = {
"name": "name = :name",
"description": "description = :description",
}
def update(self, team_id: str, fields: dict) -> bool:
"""Update mutable team fields (``name``, ``description``). updated_at is
bumped by the ``teams_set_updated_at`` trigger."""
values = {k: v for k, v in fields.items() if k in self._UPDATABLE_COLUMNS}
if not values:
return False
set_clause = ", ".join(self._UPDATABLE_COLUMNS[k] for k in values)
params = dict(values)
params["id"] = team_id
result = self._conn.execute(
text(f"UPDATE teams SET {set_clause} WHERE id = CAST(:id AS uuid)"),
params,
)
return result.rowcount > 0
def reassign_owner(self, team_id: str, new_owner_id: str) -> bool:
"""Transfer the deletion-anchor owner to another user (team_admin action)."""
result = self._conn.execute(
text("UPDATE teams SET owner_id = :owner_id WHERE id = CAST(:id AS uuid)"),
{"id": team_id, "owner_id": new_owner_id},
)
return result.rowcount > 0
def delete(self, team_id: str) -> bool:
"""Delete the team. ``team_members`` and ``team_resource_grants`` cascade;
shared resources revert to owner-only visibility."""
result = self._conn.execute(
text("DELETE FROM teams WHERE id = CAST(:id AS uuid)"),
{"id": team_id},
)
return result.rowcount > 0
@@ -131,6 +131,32 @@ class UserToolsRepository:
return row
return self.get_by_legacy_id(tool_id, user_id)
def get_by_id(self, tool_id: str) -> Optional[dict]:
"""Fetch a user_tool by id with NO ownership scoping.
Used ONLY after a team-grant authorization check (team sharing). Callers
MUST strip ``encrypted_credentials`` before returning a non-owner's tool.
"""
if not looks_like_uuid(tool_id):
return None
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE id = CAST(:id AS uuid)"),
{"id": tool_id},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
def list_by_ids(self, tool_ids) -> list[dict]:
"""Fetch user_tools whose id is in ``tool_ids`` (team-shared listing path)."""
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
if not ids:
return []
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[])) ORDER BY created_at"),
{"ids": ids},
)
return [row_to_dict(r) for r in result.fetchall()]
def list_for_user(self, user_id: str) -> list[dict]:
result = self._conn.execute(
text("SELECT * FROM user_tools WHERE user_id = :user_id ORDER BY created_at"),
+42 -8
View File
@@ -67,7 +67,7 @@ class UsersRepository:
# ------------------------------------------------------------------
# Upsert
# ------------------------------------------------------------------
def upsert(self, user_id: str) -> dict:
def upsert(self, user_id: str, email: Optional[str] = None) -> dict:
"""Ensure a row exists for ``user_id`` and return it.
Matches Mongo's ``find_one_and_update(..., $setOnInsert, upsert=True,
@@ -75,24 +75,58 @@ class UsersRepository:
are preserved untouched; if it doesn't, a new row is created with
default preferences.
The ``DO UPDATE SET user_id = EXCLUDED.user_id`` branch is a
deliberate no-op that lets ``RETURNING *`` fire on both the insert
and conflict paths (``DO NOTHING`` would suppress the returning).
``email`` (from the OIDC claim) is set on insert and refreshed on
conflict, but only when provided — ``COALESCE`` preserves a previously
stored email when a non-OIDC caller upserts with ``email=None`` (most
upserts come from request paths that don't carry the email claim).
The conflict branch also updates ``user_id`` (a no-op) so ``RETURNING *``
fires on both the insert and conflict paths.
"""
result = self._conn.execute(
text(
"""
INSERT INTO users (user_id, agent_preferences)
VALUES (:user_id, CAST(:default_prefs AS jsonb))
INSERT INTO users (user_id, email, agent_preferences)
VALUES (:user_id, :email, CAST(:default_prefs AS jsonb))
ON CONFLICT (user_id) DO UPDATE
SET user_id = EXCLUDED.user_id
SET user_id = EXCLUDED.user_id,
email = COALESCE(EXCLUDED.email, users.email)
RETURNING *
"""
),
{"user_id": user_id, "default_prefs": _DEFAULT_PREFERENCES},
{"user_id": user_id, "email": email, "default_prefs": _DEFAULT_PREFERENCES},
)
return row_to_dict(result.fetchone())
def set_email(self, user_id: str, email: Optional[str]) -> None:
"""Set a user's email when provided (targeted update, no full upsert).
Used to backfill an existing user's email from the OIDC claim on login
without re-running the upsert. No-op when ``email`` is falsy so a login
without the claim never wipes a stored value.
"""
if not email:
return
self._conn.execute(
text("UPDATE users SET email = :email WHERE user_id = :user_id"),
{"user_id": user_id, "email": email},
)
def find_by_email(self, email: str) -> Optional[dict]:
"""Return the user row whose email matches ``email`` (case-insensitive).
Used by the add-team-member-by-email flow to resolve an email to its
``user_id`` (sub). Returns None when no user has that email.
"""
if not email or not email.strip():
return None
result = self._conn.execute(
text("SELECT * FROM users WHERE lower(email) = lower(:email) LIMIT 1"),
{"email": email.strip()},
)
row = result.fetchone()
return row_to_dict(row) if row is not None else None
# ------------------------------------------------------------------
# Pinned agents
# ------------------------------------------------------------------
+4
View File
@@ -22,8 +22,10 @@ import useTokenAuth from './hooks/useTokenAuth';
import Navigation from './Navigation';
import PageNotFound from './PageNotFound';
import Setting from './settings';
import Teams from './settings/Teams';
import Notification from './components/Notification';
import ToolApprovalToast from './notifications/ToolApprovalToast';
import TeamNotificationToast from './notifications/TeamNotificationToast';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
@@ -89,6 +91,7 @@ function MainLayout() {
</div>
<UploadToast />
<ToolApprovalToast />
<TeamNotificationToast />
</div>
);
}
@@ -141,6 +144,7 @@ export default function App() {
element={<Conversation />}
/>
<Route path="/settings/*" element={<Setting />} />
<Route path="/teams" element={<Teams />} />
<Route path="/agents/*" element={<Agents />} />
<Route
path="/admin/*"
+19 -29
View File
@@ -15,7 +15,6 @@ import {
import { Agent } from './agents/types';
import conversationService from './api/services/conversationService';
import userService from './api/services/userService';
import DocsGPT3 from './assets/cute_docsgpt3.svg';
import Discord from './assets/discord.svg';
import Github from './assets/git_nav.svg';
import Pin from './assets/pin.svg';
@@ -55,6 +54,7 @@ import {
setSharedAgents,
} from './preferences/preferenceSlice';
import { AppDispatch } from './store';
import TeamSwitcher from './teams/TeamSwitcher';
import Upload from './upload/Upload';
interface NavigationProps {
@@ -392,29 +392,23 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<div
className={
'visible mt-2 flex h-[6vh] w-full items-center justify-between md:h-12'
'visible mt-2 flex h-[6vh] w-full items-center justify-between gap-1 px-2 md:h-12'
}
>
<div
className="mx-4 flex cursor-pointer items-center gap-1.5"
onClick={() => {
if (isMobile) {
setNavOpen(!navOpen);
}
}}
>
<a href="/" className="flex items-center gap-1.5">
<img className="h-9" src={DocsGPT3} alt="DocsGPT Logo" />
<p className="text-foreground text-xl font-semibold dark:text-white">
DocsGPT
</p>
</a>
<div className="min-w-0 flex-1">
<TeamSwitcher
onNavigate={() => {
if (isMobile || isTablet) {
setNavOpen(false);
}
}}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground mr-3"
className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => {
setNavOpen(!navOpen);
}}
@@ -709,19 +703,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<Menu className="size-5" strokeWidth={1.75} />
</Button>
<a
href="/"
className="absolute left-1/2 flex -translate-x-1/2 items-center"
>
<img
className="absolute right-full mr-1.5 h-9"
src={DocsGPT3}
alt="DocsGPT Logo"
<div className="absolute left-1/2 flex -translate-x-1/2 items-center">
<TeamSwitcher
onNavigate={() => {
if (isMobile || isTablet) {
setNavOpen(false);
}
}}
/>
<p className="text-foreground text-xl font-semibold dark:text-white">
DocsGPT
</p>
</a>
</div>
</div>
</div>
<ConfirmationModal
+96 -8
View File
@@ -2,6 +2,8 @@ import { SyntheticEvent, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import userService from '../api/services/userService';
import Download from '../assets/download.svg';
@@ -24,9 +26,10 @@ import {
import ConfirmationModal from '../modals/ConfirmationModal';
import MoveToFolderModal from '../modals/MoveToFolderModal';
import { ActiveState } from '../models/misc';
import ShareToTeamModal from '../teams/ShareToTeamModal';
type AgentMenuOption = {
icon: string;
icon: string | LucideIcon;
label: string;
onClick: (event: SyntheticEvent) => void;
variant: 'default' | 'destructive';
@@ -63,6 +66,7 @@ export default function AgentCard({
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const [moveModalState, setMoveModalState] = useState<ActiveState>('INACTIVE');
const [shareModalOpen, setShareModalOpen] = useState(false);
const menuOptionsConfig: Record<string, AgentMenuOption[]> = {
template: [
@@ -116,6 +120,23 @@ export default function AgentCard({
iconWidth: 14,
iconHeight: 14,
},
// 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: (e: SyntheticEvent) => {
e.stopPropagation();
setShareModalOpen(true);
},
variant: 'default' as const,
iconWidth: 14,
iconHeight: 14,
},
]
: []),
...(agent.status === 'published'
? [
{
@@ -154,6 +175,42 @@ export default function AgentCard({
iconHeight: 13,
},
],
// 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: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
if (agent.agent_type === 'workflow') {
navigate(`/agents/workflow/edit/${agent.id}`);
} else {
navigate(`/agents/edit/${agent.id}`);
}
},
variant: 'default',
iconWidth: 14,
iconHeight: 14,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'default' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
],
shared: [
{
icon: Link,
@@ -193,7 +250,9 @@ export default function AgentCard({
const menuOptions = menuOptionsConfig[section] || [];
const handleClick = () => {
if (section === 'user') {
// Team-shared agents open/run exactly like the user's own published
// agents (the run + GetAgent routes authorize team grantees server-side).
if (section === 'user' || section === 'team') {
if (agent.status === 'published') {
dispatch(setSelectedAgent(agent));
navigate(agent.id ? `/agents/${agent.id}/c/new` : '/c/new');
@@ -335,17 +394,38 @@ export default function AgentCard({
option.onClick(event as unknown as SyntheticEvent);
}}
>
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
{typeof option.icon === 'string' ? (
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
) : (
<option.icon
size={Math.max(
option.iconWidth ?? 16,
option.iconHeight ?? 16,
)}
strokeWidth={1.75}
aria-hidden="true"
/>
)}
<span>{option.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Team access badge — pinned to the top row, left of the ⋯ menu
(right-11 clears the 19px trigger at right-4) so the two align. */}
{agent.ownership === 'team' && (
<span className="bg-muted dark:bg-accent text-muted-foreground absolute top-4 right-11 z-10 flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<Users size={11} strokeWidth={2} aria-hidden="true" />
{agent.team_access === 'editor'
? t('agents.teamBadge.editor')
: t('agents.teamBadge.viewer')}
</span>
)}
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<Avatar
@@ -389,6 +469,14 @@ export default function AgentCard({
currentFolderId={agent.folder_id}
onMoveSuccess={handleMoveSuccess}
/>
{shareModalOpen && agent.id && (
<ShareToTeamModal
resourceType="agent"
resourceId={agent.id}
resourceName={agent.name}
onClose={() => setShareModalOpen(false)}
/>
)}
</div>
);
}
+1
View File
@@ -32,6 +32,7 @@ const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
{ id: 'all', labelKey: 'agents.filters.all' },
{ id: 'template', labelKey: 'agents.filters.byDocsGPT' },
{ id: 'user', labelKey: 'agents.filters.byMe' },
{ id: 'team', labelKey: 'agents.filters.team' },
{ id: 'shared', labelKey: 'agents.filters.shared' },
];
+44 -12
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);
@@ -177,6 +179,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
return jsonSchemaText.trim() !== '' && !jsonSchemaValid;
};
// Resolve a selected source id to its display name. Prefer the caller's own
// source list; fall back to the owner-resolved name embedded in the agent
// payload (source_details) so a team member viewing a shared agent sees the
// source name instead of "External KB"; only then show the generic label.
const resolveSourceLabel = (id: string): string => {
const matchedDoc = sourceDocs?.find(
(source) =>
source.id === id || source.name === id || source.retriever === id,
);
if (matchedDoc?.name) return matchedDoc.name;
const detail = agent.source_details?.find((d) => d.id === id);
if (detail?.name) return detail.name;
return t('agents.form.externalKb');
};
const handleUpload = useCallback((files: File[]) => {
if (files && files.length > 0) {
const file = files[0];
@@ -848,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>
)}
@@ -952,17 +976,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
>
{selectedSourceIds.size > 0
? Array.from(selectedSourceIds)
.map((id) => {
const matchedDoc = sourceDocs?.find(
(source) =>
source.id === id ||
source.name === id ||
source.retriever === id,
);
return (
matchedDoc?.name || t('agents.form.externalKb')
);
})
.map((id) => resolveSourceLabel(id))
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectSources')}
@@ -1003,7 +1017,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
prompts={prompts}
selectedPrompt={
prompts.find((prompt) => prompt.id === agent.prompt_id) ||
prompts[0] || {
// Owner-resolved name from the agent payload: lets a team
// member see the owner's prompt name (which isn't in their
// own prompts list). 'public' hides owner-only edit/share
// affordances on a prompt the viewer doesn't own.
(agent.prompt_name
? {
name: agent.prompt_name,
id: agent.prompt_id || 'default',
type: 'public',
}
: prompts[0]) || {
name: 'default',
id: 'default',
type: 'public',
@@ -1450,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}
+21 -7
View File
@@ -8,12 +8,12 @@ import {
setTemplateAgents,
} from '../preferences/preferenceSlice';
export type AgentSectionId = 'template' | 'user' | 'shared';
export type AgentSectionId = 'template' | 'user' | 'team' | 'shared';
export const agentSectionsConfig = [
{
id: 'template' as const,
title: 'By DocsGPT',
title: 'Templates',
description: 'Agents provided by DocsGPT',
showNewAgentButton: false,
emptyStateDescription: 'No template agents found.',
@@ -23,7 +23,7 @@ export const agentSectionsConfig = [
},
{
id: 'user' as const,
title: 'By me',
title: 'My agents',
description: 'Agents created or published by you',
showNewAgentButton: true,
emptyStateDescription: 'You don’t have any created agents yet.',
@@ -32,11 +32,25 @@ export const agentSectionsConfig = [
updateAction: setAgents,
},
{
id: 'shared' as const,
title: 'Shared with me',
description: 'Agents imported by using a public link',
// Team-shared agents are split out of the 'user' payload client-side
// (they arrive in /api/get_agents tagged ownership:'team'), so this
// section reuses the same selector/action — the hook partitions by
// ownership for display.
id: 'team' as const,
title: 'Team',
description: 'Agents your teammates shared with you',
showNewAgentButton: false,
emptyStateDescription: 'No shared agents found.',
emptyStateDescription: 'No agents have been shared with your team yet.',
fetchAgents: (token: string | null) => userService.getAgents(token),
selectData: selectAgents,
updateAction: setAgents,
},
{
id: 'shared' as const,
title: 'Discovered',
description: "Public agents you've opened via a link",
showNewAgentButton: false,
emptyStateDescription: "You haven't opened any public agents yet.",
fetchAgents: (token: string | null) => userService.getSharedAgents(token),
selectData: selectSharedAgents,
updateAction: setSharedAgents,
+36 -4
View File
@@ -45,9 +45,28 @@ export function useAgentSearch(): UseAgentSearchResult {
const [activeFilter, setActiveFilter] = useState<AgentFilterTab>('all');
const templateAgents = useSelector(selectTemplateAgents);
const userAgents = useSelector(selectAgents);
const allUserAgents = useSelector(selectAgents);
const sharedAgents = useSelector(selectSharedAgents);
// /api/get_agents returns both the caller's own agents and agents shared
// with their teams (tagged ownership:'team'). Split them so "By me" shows
// only owned agents and "Shared with my team" gets its own section.
// Preserve the null (still-loading) state for both buckets.
const userAgents = useMemo(
() =>
allUserAgents === null
? null
: allUserAgents.filter((a) => a.ownership !== 'team'),
[allUserAgents],
);
const teamAgents = useMemo(
() =>
allUserAgents === null
? null
: allUserAgents.filter((a) => a.ownership === 'team'),
[allUserAgents],
);
const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query);
}, []);
@@ -60,24 +79,27 @@ export function useAgentSearch(): UseAgentSearchResult {
(): Record<AgentSectionId, boolean> => ({
template: templateAgents !== null,
user: userAgents !== null,
team: teamAgents !== null,
shared: sharedAgents !== null,
}),
[templateAgents, userAgents, sharedAgents],
[templateAgents, userAgents, teamAgents, sharedAgents],
);
const totalAgentsBySection = useMemo(
(): Record<AgentSectionId, number> => ({
template: templateAgents?.length ?? 0,
user: userAgents?.length ?? 0,
team: teamAgents?.length ?? 0,
shared: sharedAgents?.length ?? 0,
}),
[templateAgents, userAgents, sharedAgents],
[templateAgents, userAgents, teamAgents, sharedAgents],
);
const filteredAgentsBySection = useMemo((): AgentsBySection => {
const filtered = {
template: filterAgentsByQuery(templateAgents, searchQuery),
user: filterAgentsByQuery(userAgents, searchQuery),
team: filterAgentsByQuery(teamAgents, searchQuery),
shared: filterAgentsByQuery(sharedAgents, searchQuery),
};
@@ -88,14 +110,23 @@ export function useAgentSearch(): UseAgentSearchResult {
return {
template: activeFilter === 'template' ? filtered.template : [],
user: activeFilter === 'user' ? filtered.user : [],
team: activeFilter === 'team' ? filtered.team : [],
shared: activeFilter === 'shared' ? filtered.shared : [],
};
}, [templateAgents, userAgents, sharedAgents, searchQuery, activeFilter]);
}, [
templateAgents,
userAgents,
teamAgents,
sharedAgents,
searchQuery,
activeFilter,
]);
const hasAnyAgents = useMemo(() => {
return (
totalAgentsBySection.template > 0 ||
totalAgentsBySection.user > 0 ||
totalAgentsBySection.team > 0 ||
totalAgentsBySection.shared > 0
);
}, [totalAgentsBySection]);
@@ -104,6 +135,7 @@ export function useAgentSearch(): UseAgentSearchResult {
return (
filteredAgentsBySection.template.length > 0 ||
filteredAgentsBySection.user.length > 0 ||
filteredAgentsBySection.team.length > 0 ||
filteredAgentsBySection.shared.length > 0
);
}, [filteredAgentsBySection]);
+9 -3
View File
@@ -25,6 +25,9 @@ export function useAgentsFetch(): UseAgentsFetchResult {
const [isLoading, setIsLoading] = useState<Record<AgentSectionId, boolean>>({
template: true,
user: true,
// 'team' agents are split out of the same /api/get_agents payload, so
// their loading state tracks the user fetch.
team: true,
shared: true,
});
@@ -50,7 +53,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
} catch (error) {
dispatch(setAgents([]));
} finally {
setIsLoading((prev) => ({ ...prev, user: false }));
setIsLoading((prev) => ({ ...prev, user: false, team: false }));
}
}, [token, dispatch]);
@@ -79,7 +82,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
}, [token, dispatch]);
useEffect(() => {
setIsLoading({ template: true, user: true, shared: true });
setIsLoading({ template: true, user: true, team: true, shared: true });
Promise.all([
fetchTemplateAgents(),
fetchUserAgents(),
@@ -89,7 +92,10 @@ export function useAgentsFetch(): UseAgentsFetchResult {
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]);
const isAllLoaded =
!isLoading.template && !isLoading.user && !isLoading.shared;
!isLoading.template &&
!isLoading.user &&
!isLoading.team &&
!isLoading.shared;
return {
isLoading,
+11
View File
@@ -25,6 +25,17 @@ export type Agent = {
shared?: boolean;
shared_token?: string;
shared_metadata?: any;
// Whether the current user owns this agent ('user') or only has access to
// it because a team shared it with them ('team'). Owner-only actions (e.g.
// sharing with a team) are gated on 'user'.
ownership?: 'user' | 'team';
team_access?: 'viewer' | 'editor' | null;
// 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
// names for resources the caller themselves owns).
prompt_name?: string | null;
source_details?: { id: string; name: string | null }[];
created_at?: string;
updated_at?: string;
last_used_at?: string;
+10
View File
@@ -30,6 +30,16 @@ const endpoints = {
EXPORT_AGENT: (id: string) => `/api/export_agent?id=${id}`,
IMPORT_AGENT_PLAN: '/api/import_agent/plan',
IMPORT_AGENT: '/api/import_agent',
TEAMS: '/api/teams',
TEAM: (id: string) => `/api/teams/${id}`,
TEAM_MEMBERS: (id: string) => `/api/teams/${id}/members`,
TEAM_MEMBER: (id: string, memberId: string) =>
`/api/teams/${id}/members/${encodeURIComponent(memberId)}`,
TEAM_GRANTS: (id: string) => `/api/teams/${id}/grants`,
TEAM_TRANSFER_OWNER: (id: string) => `/api/teams/${id}/transfer_owner`,
RESOURCE_SHARES: (resourceType: string, resourceId: string) =>
`/api/resource_shares?resource_type=${resourceType}&resource_id=${resourceId}`,
ALL_TEAMS: '/api/admin/teams',
PROMPTS: '/api/get_prompts',
CREATE_PROMPT: '/api/create_prompt',
DELETE_PROMPT: '/api/delete_prompt',
+161
View File
@@ -0,0 +1,161 @@
import apiClient from '../client';
import endpoints from '../endpoints';
export type TeamRole = 'team_admin' | 'team_member';
export type AccessLevel = 'viewer' | 'editor';
export type ResourceType = 'agent' | 'source' | 'prompt' | 'tool';
export type TeamMember = {
user_id: string;
// Resolved on the server from the user's profile; may be null when the user
// has never signed in or has no email on record.
email?: string | null;
role: TeamRole;
source?: string;
granted_by?: string | null;
granted_at?: string | null;
};
// A single resource-sharing grant as returned by listResourceShares.
// `target_user_id` is null/undefined for a whole-team grant, or a member's
// OIDC sub for a member-specific grant.
export type ResourceShare = {
team_id: string;
team_name?: string;
team_slug?: string;
access_level: AccessLevel;
target_user_id?: string | null;
};
// apiClient resolves to the raw fetch Response (the app convention); services
// consumed by slices/components parse the JSON here so callers get plain data.
const json = async (response: Response | unknown) => {
const r = response as Response;
if (!r || !('json' in r) || typeof r.json !== 'function') return r as unknown;
return r.json();
};
const teamsService = {
list: async (token: string | null): Promise<any> =>
json(await apiClient.get(endpoints.USER.TEAMS, token)),
create: async (
data: { name: string; description?: string },
token: string | null,
): Promise<any> =>
json(await apiClient.post(endpoints.USER.TEAMS, data, token)),
get: async (id: string, token: string | null): Promise<any> =>
json(await apiClient.get(endpoints.USER.TEAM(id), token)),
update: async (
id: string,
data: { name?: string; description?: string },
token: string | null,
): Promise<any> =>
json(await apiClient.put(endpoints.USER.TEAM(id), data, token)),
remove: async (id: string, token: string | null): Promise<any> =>
json(await apiClient.delete(endpoints.USER.TEAM(id), token)),
listMembers: async (id: string, token: string | null): Promise<any> =>
json(await apiClient.get(endpoints.USER.TEAM_MEMBERS(id), token)),
addMember: async (
id: string,
// Pass either an email (resolved to a sub server-side) or a raw user_id.
data: { email?: string; user_id?: string; role: TeamRole },
token: string | null,
): Promise<any> =>
json(await apiClient.post(endpoints.USER.TEAM_MEMBERS(id), data, token)),
setMemberRole: async (
id: string,
memberId: string,
role: TeamRole,
token: string | null,
): Promise<any> =>
json(
await apiClient.put(
endpoints.USER.TEAM_MEMBER(id, memberId),
{ role },
token,
),
),
removeMember: async (
id: string,
memberId: string,
token: string | null,
): Promise<any> =>
json(
await apiClient.delete(endpoints.USER.TEAM_MEMBER(id, memberId), token),
),
transferOwner: async (
id: string,
userId: string,
token: string | null,
): Promise<any> =>
json(
await apiClient.post(
endpoints.USER.TEAM_TRANSFER_OWNER(id),
{ user_id: userId },
token,
),
),
listGrants: async (
id: string,
resourceType: ResourceType | undefined,
token: string | null,
): Promise<any> =>
json(
await apiClient.get(
`${endpoints.USER.TEAM_GRANTS(id)}${
resourceType ? `?resource_type=${resourceType}` : ''
}`,
token,
),
),
share: async (
id: string,
data: {
resource_type: ResourceType;
resource_id: string;
access_level?: AccessLevel;
// Omitted/empty = whole team; a member's OIDC sub = that one member.
target_user_id?: string | null;
},
token: string | null,
): Promise<any> =>
json(await apiClient.post(endpoints.USER.TEAM_GRANTS(id), data, token)),
unshare: async (
id: string,
data: {
resource_type: ResourceType;
resource_id: string;
// Omitted/empty targets the whole-team grant.
target_user_id?: string | null;
},
token: string | null,
): Promise<any> => {
// Identifiers go on the query string — some proxies strip DELETE bodies.
let query = `${endpoints.USER.TEAM_GRANTS(id)}?resource_type=${
data.resource_type
}&resource_id=${encodeURIComponent(data.resource_id)}`;
if (data.target_user_id) {
query += `&target_user_id=${encodeURIComponent(data.target_user_id)}`;
}
return json(await apiClient.delete(query, token, data));
},
listResourceShares: async (
resourceType: ResourceType,
resourceId: string,
token: string | null,
): Promise<any> =>
json(
await apiClient.get(
endpoints.USER.RESOURCE_SHARES(resourceType, resourceId),
token,
),
),
listAll: async (token: string | null): Promise<any> =>
json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)),
};
export default teamsService;
+6 -4
View File
@@ -133,10 +133,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(function Modal(
{descriptionNode}
<div
className={cn(
'no-scrollbar text-foreground overflow-y-auto',
// overflow-y-auto forces overflow-x:auto, clipping focus-ring
// box-shadows; px-1 keeps them inside the clip rect.
isMobileSheet && 'min-h-0 grow px-1',
// overflow-y-auto forces overflow-x:auto and establishes a clip
// box. pt-3 reserves room so a floating Input label (which sits
// ~10px above its field) at the top of the scroll area isn't
// clipped; px-1 keeps focus-ring box-shadows inside the clip rect.
'no-scrollbar text-foreground overflow-y-auto px-1 pt-3',
isMobileSheet && 'min-h-0 grow',
contentClassName,
)}
>
+4
View File
@@ -36,6 +36,10 @@ const KNOWN_TYPES: ReadonlySet<string> = new Set([
'schedule.cancelled',
'schedule.completed',
'schedule.message.appended',
// Team-sharing notifications (teams/routes.py); consumed by
// TeamNotificationToast via selectRecentEvents.
'team.member_added',
'resource.shared',
]);
/**
+132 -8
View File
@@ -12,6 +12,18 @@
"emailUs": "Email us",
"documentation": "Documentation",
"manageAgents": "Manage Agents",
"teamAccess": {
"editor": "Editor",
"viewer": "Viewer"
},
"notifications": {
"memberAddedTitle": "Added to a team",
"memberAddedBody": "You were added to {{team}} as {{role}}",
"sharedTitle": "Shared with you",
"sharedBody": "{{type}} shared with your team: {{name}} ({{access}})",
"sharedBodyNoName": "A {{type}} was shared with your team ({{access}})",
"dismiss": "Dismiss"
},
"demo": [
{
"header": "Learn about DocsGPT",
@@ -91,6 +103,7 @@
},
"actions": "Actions",
"view": "View",
"shareWithTeam": "Share with team",
"deleteWarning": "Are you sure you want to delete \"{{name}}\"?",
"confirmDelete": "Are you sure you want to delete this file? This action cannot be undone.",
"backToAll": "Back to all sources",
@@ -216,6 +229,94 @@
"result": "Result"
}
},
"teams": {
"label": "Teams",
"subtitle": "Manage your teams, members, and shared resources.",
"newTeam": "New team",
"backToTeams": "Back to teams",
"createTeam": "Create a team",
"createTeamDescription": "Give your team a name. You can add members and share resources once it's created.",
"editTeam": "Edit",
"teamNamePlaceholder": "Team name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "What's this team for? (optional)",
"create": "Create",
"save": "Save",
"createTeamError": "Could not create team.",
"noTeams": "No teams yet.",
"noDescription": "No description",
"memberCountOne": "{{count}} member",
"memberCountOther": "{{count}} members",
"sharedCount": "{{count}} shared",
"loadError": "Failed to load teams.",
"roleAdmin": "admin",
"roleMember": "member",
"sourceManual": "Added manually",
"sourceSso": "via SSO",
"sourceScim": "via SCIM",
"teamsHeading": "Your teams",
"selectTeamHint": "Select a team to manage its members and shared resources.",
"selectTeamTitle": "No team selected",
"teamSubtitle": "Manage members and the resources shared with this team.",
"deleteTeam": "Delete team",
"deleteTeamConfirmation": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
"members": "Members",
"addMember": "Add member",
"addMemberTitle": "Add a member",
"addMemberDescription": "Invite someone by email. They must have signed in at least once.",
"teamActions": "Team actions",
"memberEmailLabel": "Member email",
"memberEmailPlaceholder": "name@example.com",
"memberRoleLabel": "Role",
"add": "Add",
"remove": "Remove",
"removeMemberConfirmation": "Remove this member from the team?",
"addMemberError": "Failed to add member (team admins only).",
"memberNotFound": "No user with that email — they must sign in once first.",
"noMembers": "No members yet. Use “Add member” to invite someone by email.",
"roleChangeError": "Could not change role (last admin is protected).",
"removeMemberError": "Could not remove member.",
"deleteTeamError": "Could not delete team.",
"updateFailed": "Update failed",
"openTeamError": "Could not load team details.",
"sharedResources": "Shared resources",
"sharedWithTeam": "Shared with this team ({{count}})",
"nothingShared": "Nothing shared yet. Share an agent, source, prompt, or tool from its menu.",
"unshare": "Unshare",
"unshareError": "Could not unshare resource.",
"resourceType": {
"agent": "Agent",
"source": "Source",
"prompt": "Prompt",
"tool": "Tool"
},
"share": {
"titleNamed": "Share \"{{name}}\"",
"titleGeneric": "Share this {{type}}",
"subtitle": "Team members can use it; editors can also modify it. You stay the owner.",
"noTeams": "You're not a member of any team yet. Create one in Settings → Teams.",
"accessLevel": {
"viewer": "Viewer",
"editor": "Editor"
},
"done": "Done",
"loadError": "Could not load current shares.",
"shareError": "Could not share with the team.",
"unshareError": "Could not unshare from the team.",
"addPlaceholder": "Add people or teams…",
"searchPlaceholder": "Add people or teams…",
"noMatches": "No people or teams found.",
"peopleWithAccess": "People with access",
"you": "You",
"owner": "Owner",
"teamsGroup": "Teams",
"peopleGroup": "People",
"teamLabel": "Team",
"viaTeam": "via {{team}}",
"removeAccess": "Remove access",
"access": "Access"
}
},
"tools": {
"label": "Tools",
"subtitle": "Browse, connect, and configure the tools and integrations that power your agents",
@@ -230,6 +331,7 @@
"edit": "Edit",
"delete": "Delete",
"reconnect": "Reconnect",
"shareWithTeam": "Share with team",
"builtIn": "Built-in",
"authStatus": {
"connected": "Connected",
@@ -793,6 +895,7 @@
"newAgent": "New Agent",
"importAgent": "Import",
"exportAgent": "Export as YAML",
"shareWithTeam": "Share with team",
"backToAll": "Back to all agents",
"pageHeader": {
"crumbs": {
@@ -811,25 +914,35 @@
"tryDifferentSearch": "Try a different search term",
"filters": {
"all": "All",
"byDocsGPT": "By DocsGPT",
"byMe": "By Me",
"shared": "Shared With Me"
"byDocsGPT": "Templates",
"byMe": "My agents",
"team": "Team",
"shared": "Discovered"
},
"teamBadge": {
"editor": "Editor",
"viewer": "Viewer"
},
"sections": {
"template": {
"title": "By DocsGPT",
"title": "Templates",
"description": "Agents provided by DocsGPT",
"emptyState": "No template agents found."
},
"user": {
"title": "By me",
"title": "My agents",
"description": "Agents created or published by you",
"emptyState": "You don't have any created agents yet."
},
"team": {
"title": "Team",
"description": "Agents your teammates shared with you",
"emptyState": "No agents have been shared with your team yet."
},
"shared": {
"title": "Shared with me",
"description": "Agents imported by using a public link",
"emptyState": "No shared agents found."
"title": "Discovered",
"description": "Public agents you've opened via a link",
"emptyState": "You haven't opened any public agents yet."
}
},
"form": {
@@ -1061,6 +1174,17 @@
"navigation": {
"agents": "Agents"
},
"teams": {
"switcher": {
"ariaLabel": "Switch team",
"personal": "Personal account",
"personalSubtitle": "Personal workspace",
"roleAdmin": "Admin",
"roleMember": "Member",
"manageTeam": "Manage team",
"createTeam": "Create team"
}
},
"notification": {
"ariaLabel": "Notification",
"closeAriaLabel": "Close notification"
+6
View File
@@ -16,6 +16,12 @@ export type Doc = {
provider?: string;
// Derived server-side from ingest_chunk_progress (sources API).
ingestStatus?: 'processing' | 'failed';
// 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 = {
@@ -0,0 +1,169 @@
import { X } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../components/ui/button';
import {
dismissShareNotification,
selectDismissedShareNotifications,
selectRecentEvents,
type SSEEvent,
} from './notificationsSlice';
// Backlog replay on reload re-delivers up to ~24h of events; skip anything
// older than the stream-retention window so a stale share doesn't pop.
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
// Informational toasts auto-dismiss after a beat (and persist the dismissal,
// so backlog replay won't re-pop them) — unlike the action-required tool
// approval toast, which stays until acted on.
const AUTO_DISMISS_MS = 8000;
const MAX_VISIBLE = 3;
const TEAM_NOTIFICATION_TYPES = new Set([
'team.member_added',
'resource.shared',
]);
/**
* A single team-sharing toast that owns its OWN auto-dismiss timer, keyed on
* its event id. Per-toast (rather than a shared parent timer) so a newly
* arriving sibling toast can't reset an in-flight toast's countdown.
*/
function ShareToast({
id,
title,
body,
dismissLabel,
onDismiss,
}: {
id: string;
title: string;
body: string;
dismissLabel: string;
onDismiss: (id: string) => void;
}) {
useEffect(() => {
const timer = window.setTimeout(() => onDismiss(id), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
}, [id, onDismiss]);
return (
<div className="border-border bg-card shadow-toast w-[271px] overflow-hidden rounded-2xl border">
<div className="bg-accent/50 dark:bg-muted flex items-center justify-between px-4 py-3">
<h3 className="dark:text-foreground text-sm leading-[16.5px] font-medium text-black">
{title}
</h3>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onDismiss(id)}
className="h-8 w-8 p-0 text-black opacity-70 transition-opacity hover:bg-transparent hover:opacity-100 dark:text-white"
aria-label={dismissLabel}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-5 py-3">
<p className="dark:text-muted-foreground text-sm leading-[18px] font-normal text-black">
{body}
</p>
</div>
</div>
);
}
/**
* Transient toasts for team-sharing events (``team.member_added`` and
* ``resource.shared``) delivered over the per-user SSE channel. Mirrors the
* ToolApprovalToast surface, but auto-dismisses since these are purely
* informational. Dismissal (manual or timed) is persisted so the SSE backlog
* replay on reload doesn't re-surface an already-seen notification.
*/
export default function TeamNotificationToast() {
const dispatch = useDispatch();
const { t } = useTranslation();
const events = useSelector(selectRecentEvents);
const dismissed = useSelector(selectDismissedShareNotifications);
const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]);
const onDismiss = useCallback(
(id: string) => dispatch(dismissShareNotification(id)),
[dispatch],
);
const now = Date.now();
const visible: SSEEvent[] = [];
const seen = new Set<string>();
for (const event of events) {
if (!TEAM_NOTIFICATION_TYPES.has(event.type)) continue;
if (!event.id || dismissedSet.has(event.id) || seen.has(event.id)) continue;
if (event.ts) {
const age = now - Date.parse(event.ts);
if (Number.isFinite(age) && age > MAX_AGE_MS) continue;
}
seen.add(event.id);
visible.push(event);
if (visible.length >= MAX_VISIBLE) break;
}
if (visible.length === 0) return null;
const describe = (event: SSEEvent): { title: string; body: string } => {
const p = (event.payload ?? {}) as Record<string, unknown>;
if (event.type === 'team.member_added') {
const team = (p.team_name as string) || (p.team_id as string) || '';
const role =
p.role === 'team_admin'
? t('settings.teams.roleAdmin')
: t('settings.teams.roleMember');
return {
title: t('notifications.memberAddedTitle'),
body: t('notifications.memberAddedBody', { team, role }),
};
}
// resource.shared
const type = t(`settings.teams.resourceType.${p.resource_type}`, {
defaultValue: String(p.resource_type ?? 'resource'),
});
const access =
p.access_level === 'editor'
? t('teamAccess.editor')
: t('teamAccess.viewer');
const name = (p.resource_name as string) || '';
return {
title: t('notifications.sharedTitle'),
body: name
? t('notifications.sharedBody', { name, type, access })
: t('notifications.sharedBodyNoName', { type, access }),
};
};
return (
// Standard bottom-right toast rail. UploadToast / ToolApprovalToast use the
// same corner but rarely co-occur with team-share toasts.
<div
className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2"
onMouseDown={(e) => e.stopPropagation()}
role="status"
aria-live="polite"
aria-atomic="true"
>
{visible.map((event) => {
const { title, body } = describe(event);
return (
<ShareToast
key={event.id}
id={event.id as string}
title={title}
body={body}
dismissLabel={t('notifications.dismiss')}
onDismiss={onDismiss}
/>
);
})}
</div>
);
}
@@ -4,6 +4,8 @@ import { RootState } from '../store';
import { loadDismissed, saveDismissed } from './dismissedPersistence';
const DISMISSED_TOOL_APPROVALS_STORAGE_KEY = 'docsgpt:dismissedToolApprovals';
const DISMISSED_SHARE_NOTIFICATIONS_STORAGE_KEY =
'docsgpt:dismissedShareNotifications';
/**
* Envelope shape published by the backend SSE endpoint
@@ -59,11 +61,20 @@ interface NotificationsState {
* plausible approval-pending window.
*/
dismissedToolApprovals: Array<{ id: string; at: number }>;
/**
* Event ids of team-sharing notifications (``team.member_added`` /
* ``resource.shared``) the user dismissed or that auto-dismissed,
* persisted so SSE backlog replay doesn't re-pop them. Same TTL + FIFO
* eviction as the tool-approval set.
*/
dismissedShareNotifications: Array<{ id: string; at: number }>;
}
const RECENT_EVENTS_CAP = 100;
const DISMISSED_TOOL_APPROVALS_CAP = 200;
const DISMISSED_TOOL_APPROVALS_TTL_MS = 24 * 60 * 60 * 1000;
const DISMISSED_SHARE_NOTIFICATIONS_CAP = 200;
const DISMISSED_SHARE_NOTIFICATIONS_TTL_MS = 24 * 60 * 60 * 1000;
const initialState: NotificationsState = {
health: 'connecting',
@@ -76,6 +87,10 @@ const initialState: NotificationsState = {
DISMISSED_TOOL_APPROVALS_STORAGE_KEY,
DISMISSED_TOOL_APPROVALS_TTL_MS,
),
dismissedShareNotifications: loadDismissed(
DISMISSED_SHARE_NOTIFICATIONS_STORAGE_KEY,
DISMISSED_SHARE_NOTIFICATIONS_TTL_MS,
),
};
export const notificationsSlice = createSlice({
@@ -160,6 +175,35 @@ export const notificationsSlice = createSlice({
state.dismissedToolApprovals,
);
},
/**
* Suppress a team-sharing notification (``team.member_added`` /
* ``resource.shared``) by SSE event id — fired on close OR on the
* informational auto-dismiss timer. Persisted so backlog replay on
* reload doesn't re-pop an already-seen notification.
*/
dismissShareNotification: (state, action: PayloadAction<string>) => {
const id = action.payload;
const now = Date.now();
const cutoff = now - DISMISSED_SHARE_NOTIFICATIONS_TTL_MS;
state.dismissedShareNotifications =
state.dismissedShareNotifications.filter(
(entry) => entry.at >= cutoff && entry.id !== id,
);
state.dismissedShareNotifications.push({ id, at: now });
if (
state.dismissedShareNotifications.length >
DISMISSED_SHARE_NOTIFICATIONS_CAP
) {
state.dismissedShareNotifications =
state.dismissedShareNotifications.slice(
-DISMISSED_SHARE_NOTIFICATIONS_CAP,
);
}
saveDismissed(
DISMISSED_SHARE_NOTIFICATIONS_STORAGE_KEY,
state.dismissedShareNotifications,
);
},
/**
* Revoke a pending approval when the backend says it can no longer be
* acted on (``tool.approval.cleared`` — the message failed or the
@@ -228,6 +272,7 @@ export const {
sseLastEventIdAdvanced,
clearRecentEvents,
dismissToolApproval,
dismissShareNotification,
resolveToolApproval,
} = notificationsSlice.actions;
@@ -255,4 +300,9 @@ export const selectDismissedToolApprovals = createSelector(
(entries) => entries.map((entry) => entry.id),
);
export const selectDismissedShareNotifications = createSelector(
(state: RootState) => state.notifications.dismissedShareNotifications,
(entries) => entries.map((entry) => entry.id),
);
export default notificationsSlice.reducer;
+40 -1
View File
@@ -1,4 +1,4 @@
import { ChevronDown, Copy, Eye, Pencil, Trash2 } from 'lucide-react';
import { ChevronDown, Copy, Eye, Pencil, Trash2, Users } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -21,6 +21,7 @@ import {
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, PromptProps } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import PromptsModal from '../preferences/PromptsModal';
import { cn } from '@/lib/utils';
@@ -69,6 +70,11 @@ export default function Prompts({
name: string;
} | null>(null);
const [promptToShare, setPromptToShare] = React.useState<{
id: string;
name: string;
} | null>(null);
const handleSelectPrompt = (prompt: {
name: string;
id: string;
@@ -332,6 +338,11 @@ export default function Prompts({
{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';
return (
<CommandItem
key={prompt.id}
@@ -376,6 +387,26 @@ export default function Prompts({
>
<Copy className="text-muted-foreground group-hover/btn:text-foreground h-3.5 w-3.5" />
</Button>
{canShare && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
setPromptToShare({
id: prompt.id,
name: prompt.name,
});
}}
className="group/btn hover:bg-foreground/15 dark:hover:bg-foreground/20 h-auto w-auto rounded p-1"
aria-label={t('agents.shareWithTeam')}
title={t('agents.shareWithTeam')}
>
<Users className="text-muted-foreground group-hover/btn:text-foreground h-3.5 w-3.5" />
</Button>
)}
{canModify && (
<Button
type="button"
@@ -447,6 +478,14 @@ export default function Prompts({
variant="danger"
/>
)}
{promptToShare && (
<ShareToTeamModal
resourceType="prompt"
resourceId={promptToShare.id}
resourceName={promptToShare.name}
onClose={() => setPromptToShare(null)}
/>
)}
</>
);
}
+58 -8
View File
@@ -1,4 +1,5 @@
import { Search as SearchIcon } from 'lucide-react';
import { Search as SearchIcon, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -26,6 +27,7 @@ import { Input } from '../components/ui/input';
import { useDarkTheme, useDebouncedValue, useLoaderState } from '../hooks';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
import {
selectToken,
@@ -45,7 +47,7 @@ import ConnectorTree from '../components/ConnectorTree';
import Chunks from '../components/Chunks';
type SourceMenuOption = {
icon: string;
icon: string | LucideIcon;
label: string;
onClick: () => void;
variant: 'default' | 'destructive';
@@ -101,6 +103,7 @@ export default function Sources({
{ label: t('settings.sources.syncFrequency.monthly'), value: 'monthly' },
];
const [documentToView, setDocumentToView] = useState<Doc>();
const [documentToShare, setDocumentToShare] = useState<Doc | null>(null);
const [syncMenuState, setSyncMenuState] = useState<{
isOpen: boolean;
docId: string | null;
@@ -371,6 +374,21 @@ 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) {
actions.push({
icon: Users,
label: t('settings.sources.shareWithTeam'),
onClick: () => {
setDocumentToShare(document);
},
iconWidth: 16,
iconHeight: 16,
variant: 'default',
});
}
actions.push({
icon: Trash,
label: t('convTile.delete'),
@@ -565,12 +583,23 @@ export default function Sources({
variant={option.variant}
onSelect={() => option.onClick()}
>
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
{typeof option.icon === 'string' ? (
<img
src={option.icon}
alt=""
width={option.iconWidth ?? 16}
height={option.iconHeight ?? 16}
/>
) : (
<option.icon
size={Math.max(
option.iconWidth ?? 16,
option.iconHeight ?? 16,
)}
strokeWidth={1.75}
aria-hidden="true"
/>
)}
<span>{option.label}</span>
</DropdownMenuItem>
),
@@ -582,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')}
@@ -668,6 +709,15 @@ export default function Sources({
variant="danger"
/>
)}
{documentToShare && documentToShare.id && (
<ShareToTeamModal
resourceType="source"
resourceId={documentToShare.id}
resourceName={documentToShare.name}
onClose={() => setDocumentToShare(null)}
/>
)}
</div>
);
}
+993
View File
@@ -0,0 +1,993 @@
import {
ArrowLeft,
Bot,
ChevronRight,
FileText,
MessageSquare,
MoreVertical,
Pencil,
Plus,
Trash2,
Users,
Wrench,
} from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate } from 'react-router-dom';
import teamsService, {
ResourceType,
TeamRole,
} from '../api/services/teamsService';
import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import SkeletonLoader from '../components/SkeletonLoader';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Input } from '../components/ui/input';
import { Modal } from '../components/ui/modal';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { useDarkTheme } from '../hooks';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import {
selectAgents,
selectPrompts,
selectSourceDocs,
selectToken,
setAgents,
} from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import {
createTeam,
deleteTeam,
loadTeams,
selectTeams,
selectTeamsError,
selectTeamsLoading,
Team,
} from '../teams/teamsSlice';
type Member = {
user_id: string;
email?: string | null;
role: TeamRole;
source: string;
};
type Grant = {
resource_type: string;
resource_id: string;
access_level: string;
};
const RESOURCE_TYPES: ReadonlyArray<ResourceType> = [
'agent',
'source',
'prompt',
'tool',
];
// 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 =>
sub.length > 24 ? `${sub.slice(0, 12)}…${sub.slice(-8)}` : sub;
// First character of a label, uppercased, for initial avatars (ported from
// ShareToTeamModal to keep avatar treatment consistent across team UIs).
const initialOf = (label: string): string => {
const trimmed = label.trim();
return trimmed ? trimmed[0].toUpperCase() : '?';
};
export default function Teams() {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const location = useLocation();
const navigate = useNavigate();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const teams = useSelector(selectTeams);
const teamsLoading = useSelector(selectTeamsLoading);
const teamsError = useSelector(selectTeamsError);
const agents = useSelector(selectAgents);
const sourceDocs = useSelector(selectSourceDocs);
const prompts = useSelector(selectPrompts);
const [selected, setSelected] = useState<Team | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [grants, setGrants] = useState<Grant[]>([]);
// Tools aren't kept in Redux; lazily fetch the user's tool instances (keyed
// by id) only when a team actually has a tool grant, so shared-tool rows can
// render a friendly name instead of the raw instance UUID.
const [toolsById, setToolsById] = useState<Record<string, string>>({});
const [newTeamName, setNewTeamName] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editError, setEditError] = useState<string | null>(null);
const [newMemberEmail, setNewMemberEmail] = useState('');
const [newMemberRole, setNewMemberRole] = useState<TeamRole>('team_member');
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [addMemberError, setAddMemberError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleteTeamModalState, setDeleteTeamModalState] =
useState<ActiveState>('INACTIVE');
const [teamToDelete, setTeamToDelete] = useState<Team | null>(null);
const [removeMemberModalState, setRemoveMemberModalState] =
useState<ActiveState>('INACTIVE');
const [memberToRemove, setMemberToRemove] = useState<string | null>(null);
useEffect(() => {
dispatch(loadTeams({ token }));
}, []);
const resourceTypeLabel = (type: string) =>
RESOURCE_TYPES.includes(type as ResourceType)
? t(`settings.teams.resourceType.${type}`)
: type;
const memberLabel = (m: Member): string =>
m.email && m.email.trim() !== '' ? m.email : truncateSub(m.user_id);
// Where a membership came from: manual add, an SSO group claim, or SCIM
// provisioning. Falls back to the raw source value for forward-compat.
const sourceLabel = (s: string): string =>
(
({
manual: t('settings.teams.sourceManual'),
oidc_group: t('settings.teams.sourceSso'),
scim: t('settings.teams.sourceScim'),
}) as Record<string, string>
)[s] ?? s;
// Resolve a grant's resource_id to its display name from the Redux-hydrated
// lists (agents/sources/prompts) or the lazily-fetched tools map. Derived at
// 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 => {
switch (g.resource_type) {
case 'agent':
return (
agents?.find((a) => a.id === g.resource_id)?.name ??
truncateSub(g.resource_id)
);
case 'source':
return (
sourceDocs?.find((d) => d.id === g.resource_id)?.name ??
truncateSub(g.resource_id)
);
case 'prompt':
return (
prompts?.find((p) => p.id === g.resource_id)?.name ??
truncateSub(g.resource_id)
);
case 'tool':
return toolsById[g.resource_id] ?? truncateSub(g.resource_id);
default:
return truncateSub(g.resource_id);
}
};
// Lucide icon for a grant's resource type, shown in the row's type tile.
const resourceTypeIcon = (type: string): ReactNode => {
const props = { size: 16, strokeWidth: 1.75, 'aria-hidden': true } as const;
switch (type) {
case 'agent':
return <Bot {...props} />;
case 'source':
return <FileText {...props} />;
case 'prompt':
return <MessageSquare {...props} />;
case 'tool':
return <Wrench {...props} />;
default:
return <FileText {...props} />;
}
};
// Friendly label for a grant's access level; reuses the share-modal keys
// when present, otherwise renders the raw value.
const accessLevelLabel = (level: string): string => {
const key = `settings.teams.share.accessLevel.${level}`;
const label = t(key);
return label === key ? level : label;
};
const openTeam = async (team: Team) => {
setSelected(team);
setError(null);
// Clear the previous team's data so the detail view doesn't flash stale
// members/grants while this team's fetch is in flight.
setMembers([]);
setGrants([]);
try {
const m = await teamsService.listMembers(team.id, token);
setMembers(m?.members ?? []);
const g = await teamsService.listGrants(team.id, undefined, token);
setGrants(g?.grants ?? []);
// 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
// to a name. Own try/catch — a failure just leaves the truncated-id row.
if (
!agents &&
g?.grants?.some((x: Grant) => x.resource_type === 'agent')
) {
try {
const res = await userService.getAgents(token);
const data = await res.json();
// Guard: only push an actual array into Redux — a non-2xx body
// (e.g. { success:false }) would otherwise poison selectAgents and
// crash resolveResourceName / other consumers app-wide.
if (Array.isArray(data)) dispatch(setAgents(data));
} catch {
// Non-fatal: agent rows fall back to a truncated resource id.
}
}
// Resolve tool names only when this team actually shares a tool. Kept in
// its own try/catch so a tools fetch failure never blocks the already-
// rendered members/grants — the row just falls back to the truncated id.
if (g?.grants?.some((x: Grant) => x.resource_type === 'tool')) {
try {
const res = await userService.getUserTools(token);
const data = await res.json();
const map: Record<string, string> = {};
(data?.data ?? data?.tools ?? []).forEach(
(tl: {
id: string;
customName?: string;
displayName?: string;
name?: string;
}) => {
map[tl.id] = tl.customName || tl.displayName || tl.name || tl.id;
},
);
setToolsById(map);
} catch {
// Non-fatal: tool rows fall back to a truncated resource id.
}
}
} catch {
// Surface the failure instead of silently rendering an empty team.
setError(t('settings.teams.openTeamError'));
}
};
const handleCreate = async () => {
if (!newTeamName.trim()) return;
setCreateError(null);
try {
const created = await dispatch(
createTeam({ name: newTeamName.trim(), token }),
).unwrap();
setNewTeamName('');
setCreateOpen(false);
openTeam(created);
} catch {
// Keep the modal open and surface the error so the user can retry.
setCreateError(t('settings.teams.createTeamError'));
}
};
const openCreateModal = () => {
setNewTeamName('');
setCreateError(null);
setCreateOpen(true);
};
const closeCreateModal = () => {
setCreateOpen(false);
setNewTeamName('');
setCreateError(null);
};
const openEditModal = () => {
if (!selected) return;
setEditName(selected.name);
setEditDescription(selected.description ?? '');
setEditError(null);
setEditOpen(true);
};
const closeEditModal = () => {
setEditOpen(false);
setEditError(null);
};
const handleEditSave = async () => {
if (!selected || !editName.trim()) return;
const name = editName.trim();
const description = editDescription.trim();
try {
const res = await teamsService.update(
selected.id,
{ name, description },
token,
);
if (!res || res.success === false) {
setEditError(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'));
}
};
// Consume navigation intent from the team switcher: "Manage team" passes
// openTeamId (open that team's detail directly); "Create team" passes
// create:true (pop the create modal). Consumed once, then the history state
// is cleared so a refresh/back doesn't re-trigger it.
const pendingOpenId = useRef<string | null>(null);
// Re-run on every navigation (location.key) so it also fires when the user is
// already on /teams and re-triggers from the switcher. Open the team
// immediately if teams are loaded; otherwise defer to the effect below.
useEffect(() => {
const st = location.state as {
create?: boolean;
openTeamId?: string;
} | null;
if (!st || (!st.create && !st.openTeamId)) return;
if (st.create) openCreateModal();
if (st.openTeamId) {
pendingOpenId.current = st.openTeamId;
const team = teams?.find((tm) => tm.id === st.openTeamId);
if (team) {
pendingOpenId.current = null;
openTeam(team);
}
}
// Clear the history state so a refresh/back doesn't re-trigger it.
navigate(location.pathname, { replace: true, state: null });
}, [location.key]);
// Open the requested team once teams finish loading (deferred case).
useEffect(() => {
if (!pendingOpenId.current) return;
const team = teams?.find((tm) => tm.id === pendingOpenId.current);
if (team) {
pendingOpenId.current = null;
openTeam(team);
}
}, [teams]);
const openAddMemberModal = () => {
setNewMemberEmail('');
setNewMemberRole('team_member');
setAddMemberError(null);
setAddMemberOpen(true);
};
const closeAddMemberModal = () => {
setAddMemberOpen(false);
setAddMemberError(null);
};
const handleAddMember = async () => {
if (!selected || !newMemberEmail.trim()) return;
setAddMemberError(null);
try {
const res = await teamsService.addMember(
selected.id,
{ email: newMemberEmail.trim(), role: newMemberRole },
token,
);
if (res?.success === false) {
// The backend returns 404 with a message when the email maps to no
// known user; surface its message (e.g. "they must sign in first").
setAddMemberError(res.message ?? t('settings.teams.memberNotFound'));
return;
}
setNewMemberEmail('');
setNewMemberRole('team_member');
setAddMemberOpen(false);
openTeam(selected);
} catch {
setAddMemberError(t('settings.teams.addMemberError'));
}
};
const handleRoleChange = async (memberId: string, role: TeamRole) => {
if (!selected) return;
setError(null);
try {
const res = await teamsService.setMemberRole(
selected.id,
memberId,
role,
token,
);
if (res?.success === false)
setError(res.message ?? t('settings.teams.updateFailed'));
openTeam(selected);
} catch {
setError(t('settings.teams.roleChangeError'));
}
};
const requestRemoveMember = (memberId: string) => {
setMemberToRemove(memberId);
setRemoveMemberModalState('ACTIVE');
};
const confirmRemoveMember = async () => {
if (!selected || !memberToRemove) return;
const memberId = memberToRemove;
setMemberToRemove(null);
try {
await teamsService.removeMember(selected.id, memberId, token);
openTeam(selected);
} catch {
setError(t('settings.teams.removeMemberError'));
}
};
const requestDeleteTeam = (team: Team) => {
setTeamToDelete(team);
setDeleteTeamModalState('ACTIVE');
};
const confirmDeleteTeam = async () => {
if (!teamToDelete) return;
const team = teamToDelete;
setTeamToDelete(null);
try {
await dispatch(deleteTeam({ id: team.id, token })).unwrap();
if (selected?.id === team.id) setSelected(null);
} catch {
setError(t('settings.teams.deleteTeamError'));
}
};
const handleUnshare = async (grant: Grant) => {
if (!selected) return;
setError(null);
try {
await teamsService.unshare(
selected.id,
{
resource_type: grant.resource_type as ResourceType,
resource_id: grant.resource_id,
},
token,
);
openTeam(selected);
} catch {
setError(t('settings.teams.unshareError'));
}
};
const isAdmin = selected?.member_role === 'team_admin';
const roleBadge = (role: TeamRole) => (
<span
className={`rounded-full px-2 py-0.5 text-xs ${
role === 'team_admin'
? 'bg-muted-foreground/15 text-foreground'
: 'bg-muted-foreground/10 text-muted-foreground'
}`}
>
{role === 'team_admin'
? t('settings.teams.roleAdmin')
: t('settings.teams.roleMember')}
</span>
);
const emptyState = (message: string, extra?: ReactNode) => (
<div className="flex flex-col items-center justify-center py-12">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt=""
aria-hidden="true"
className="mx-auto mb-6 h-32 w-32"
/>
<p className="text-muted-foreground max-w-sm text-center text-sm">
{message}
</p>
{extra}
</div>
);
return (
<div className="h-full overflow-auto p-4 md:p-12">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-foreground text-2xl font-bold">
{t('settings.teams.label')}
</p>
<p className="text-muted-foreground text-sm">
{t('settings.teams.subtitle')}
</p>
</div>
<Button className="shrink-0" onClick={openCreateModal}>
<Plus size={16} strokeWidth={1.75} aria-hidden />
{t('settings.teams.newTeam')}
</Button>
</div>
{!selected ? (
<div className="mx-auto mt-8 w-full max-w-5xl">
{teamsLoading ? (
<SkeletonLoader component="default" />
) : teamsError ? (
<p className="text-destructive text-sm" role="alert">
{t('settings.teams.loadError')}
</p>
) : teams.length === 0 ? (
emptyState(
t('settings.teams.noTeams'),
<Button
variant="ghost"
className="mt-4"
onClick={openCreateModal}
>
<Plus size={16} strokeWidth={1.75} aria-hidden />
{t('settings.teams.newTeam')}
</Button>,
)
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<button
key={team.id}
className="border-border bg-muted dark:bg-accent hover:border-primary/40 group flex h-full flex-col gap-3 rounded-2xl border p-4 text-left transition-colors"
onClick={() => openTeam(team)}
>
<div className="flex items-center gap-3">
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-muted-foreground/15 text-foreground flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-medium"
>
{initialOf(team.name)}
</Avatar>
</span>
<span className="text-foreground min-w-0 flex-1 truncate text-sm font-semibold">
{team.name}
</span>
{roleBadge(team.member_role ?? 'team_member')}
<ChevronRight
className="text-muted-foreground shrink-0 transition-transform group-hover:translate-x-0.5"
size={18}
strokeWidth={1.75}
aria-hidden
/>
</div>
{team.description ? (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{team.description}
</p>
) : (
<p className="text-muted-foreground/50 text-xs italic">
{t('settings.teams.noDescription')}
</p>
)}
<div className="text-muted-foreground mt-auto flex items-center gap-1.5 text-xs">
<Users size={13} strokeWidth={1.75} aria-hidden />
<span>
{t(
(team.member_count ?? 0) === 1
? 'settings.teams.memberCountOne'
: 'settings.teams.memberCountOther',
{ count: team.member_count ?? 0 },
)}
</span>
<span aria-hidden>·</span>
<span>
{t('settings.teams.sharedCount', {
count: team.shared_count ?? 0,
})}
</span>
</div>
</button>
))}
</div>
)}
</div>
) : (
<div className="mx-auto mt-8 max-w-5xl space-y-6">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground -ml-2"
onClick={() => {
setSelected(null);
setMembers([]);
setGrants([]);
setError(null);
}}
>
<ArrowLeft size={16} strokeWidth={1.75} aria-hidden />
{t('settings.teams.backToTeams')}
</Button>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-muted-foreground/15 text-foreground flex size-10 shrink-0 items-center justify-center rounded-md text-base font-medium"
>
{initialOf(selected.name)}
</Avatar>
</span>
<div className="min-w-0">
<h3 className="text-foreground truncate text-xl font-bold">
{selected.name}
</h3>
{selected.description && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{selected.description}
</p>
)}
</div>
</div>
{isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label={t('settings.teams.teamActions')}
>
<MoreVertical size={18} strokeWidth={1.75} aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
<DropdownMenuItem onSelect={openEditModal}>
<Pencil size={15} strokeWidth={1.75} aria-hidden />
<span>{t('settings.teams.editTeam')}</span>
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => requestDeleteTeam(selected)}
>
<Trash2 size={15} strokeWidth={1.75} aria-hidden />
<span>{t('settings.teams.deleteTeam')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{error && (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
)}
<div className="border-border mt-6 border-t pt-6">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t('settings.teams.members')} · {members.length}
</h4>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={openAddMemberModal}
>
<Plus size={15} strokeWidth={1.75} aria-hidden />
{t('settings.teams.addMember')}
</Button>
)}
</div>
{members.length === 0 ? (
emptyState(t('settings.teams.noMembers'))
) : (
<ul>
{members.map((m) => (
<li
key={`${m.user_id}-${m.source}`}
className="border-border/60 flex items-center gap-3 border-b px-1 py-2.5 last:border-b-0"
>
<span aria-hidden="true" className="contents">
<Avatar
alt=""
className="bg-muted-foreground/15 text-foreground flex size-8 items-center justify-center rounded-full text-sm font-medium"
>
{initialOf(memberLabel(m))}
</Avatar>
</span>
<div className="min-w-0 flex-1">
<p
className="text-foreground truncate text-sm font-medium"
title={memberLabel(m)}
>
{memberLabel(m)}
</p>
<p className="text-muted-foreground truncate text-xs">
{sourceLabel(m.source)}
</p>
</div>
{isAdmin ? (
<Select
value={m.role}
onValueChange={(value) =>
handleRoleChange(m.user_id, value as TeamRole)
}
>
<SelectTrigger
size="sm"
className="shrink-0"
aria-label={t('settings.teams.memberRoleLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_member">
{t('settings.teams.roleMember')}
</SelectItem>
<SelectItem value="team_admin">
{t('settings.teams.roleAdmin')}
</SelectItem>
</SelectContent>
</Select>
) : (
<span className="shrink-0">{roleBadge(m.role)}</span>
)}
{isAdmin && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={t('settings.teams.remove')}
title={t('settings.teams.remove')}
onClick={() => requestRemoveMember(m.user_id)}
>
<Trash2 size={16} strokeWidth={1.75} aria-hidden />
</Button>
)}
</li>
))}
</ul>
)}
</div>
<div className="border-border mt-6 border-t pt-6">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold tracking-wide uppercase">
{t('settings.teams.sharedResources')} · {grants.length}
</h4>
{grants.length === 0 ? (
emptyState(t('settings.teams.nothingShared'))
) : (
<ul>
{grants.map((g) => (
<li
key={`${g.resource_type}-${g.resource_id}`}
className="border-border/60 flex items-center gap-3 border-b px-1 py-2.5 last:border-b-0"
>
<span
aria-hidden="true"
className="bg-muted-foreground/10 text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
title={resourceTypeLabel(g.resource_type)}
>
{resourceTypeIcon(g.resource_type)}
</span>
<span
className="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
title={g.resource_id}
>
{resolveResourceName(g)}
</span>
<span className="bg-muted-foreground/10 text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
{accessLevelLabel(g.access_level)}
</span>
{isAdmin && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive shrink-0"
aria-label={t('settings.teams.unshare')}
title={t('settings.teams.unshare')}
onClick={() => handleUnshare(g)}
>
<Trash2 size={16} strokeWidth={1.75} aria-hidden />
</Button>
)}
</li>
))}
</ul>
)}
</div>
</div>
)}
<Modal
open={createOpen}
onOpenChange={(open) =>
open ? setCreateOpen(true) : closeCreateModal()
}
size="sm"
mobileVariant="sheet"
title={t('settings.teams.createTeam')}
description={t('settings.teams.createTeamDescription')}
footer={
<>
<Button variant="ghost" onClick={closeCreateModal}>
{t('cancel')}
</Button>
<Button onClick={handleCreate} disabled={!newTeamName.trim()}>
{t('settings.teams.create')}
</Button>
</>
}
>
<Input
type="text"
autoFocus
label={t('settings.teams.teamNamePlaceholder')}
aria-label={t('settings.teams.teamNamePlaceholder')}
value={newTeamName}
onChange={(e) => setNewTeamName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
{createError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{createError}
</p>
)}
</Modal>
<Modal
open={editOpen}
onOpenChange={(open) => (open ? setEditOpen(true) : closeEditModal())}
size="sm"
mobileVariant="sheet"
title={t('settings.teams.editTeam')}
footer={
<>
<Button variant="ghost" onClick={closeEditModal}>
{t('cancel')}
</Button>
<Button onClick={handleEditSave} disabled={!editName.trim()}>
{t('settings.teams.save')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input
type="text"
autoFocus
label={t('settings.teams.teamNamePlaceholder')}
aria-label={t('settings.teams.teamNamePlaceholder')}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="team-edit-description"
className="text-foreground text-sm font-medium"
>
{t('settings.teams.descriptionLabel')}
</label>
<textarea
id="team-edit-description"
rows={3}
className="border-border bg-background text-foreground focus-visible:ring-ring/50 focus-visible:border-ring placeholder:text-muted-foreground w-full resize-none rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
placeholder={t('settings.teams.descriptionPlaceholder')}
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
/>
</div>
</div>
{editError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{editError}
</p>
)}
</Modal>
<Modal
open={addMemberOpen}
onOpenChange={(open) =>
open ? setAddMemberOpen(true) : closeAddMemberModal()
}
size="sm"
mobileVariant="sheet"
title={t('settings.teams.addMemberTitle')}
description={t('settings.teams.addMemberDescription')}
footer={
<>
<Button variant="ghost" onClick={closeAddMemberModal}>
{t('cancel')}
</Button>
<Button onClick={handleAddMember} disabled={!newMemberEmail.trim()}>
{t('settings.teams.add')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<Input
type="email"
autoFocus
label={t('settings.teams.memberEmailLabel')}
aria-label={t('settings.teams.memberEmailLabel')}
placeholder={t('settings.teams.memberEmailPlaceholder')}
value={newMemberEmail}
onChange={(e) => setNewMemberEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddMember()}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="add-member-role"
className="text-foreground text-sm font-medium"
>
{t('settings.teams.memberRoleLabel')}
</label>
<Select
value={newMemberRole}
onValueChange={(value) => setNewMemberRole(value as TeamRole)}
>
<SelectTrigger
id="add-member-role"
aria-label={t('settings.teams.memberRoleLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_member">
{t('settings.teams.roleMember')}
</SelectItem>
<SelectItem value="team_admin">
{t('settings.teams.roleAdmin')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{addMemberError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{addMemberError}
</p>
)}
</Modal>
<ConfirmationModal
message={t('settings.teams.deleteTeamConfirmation', {
name: teamToDelete?.name ?? '',
})}
modalState={deleteTeamModalState}
setModalState={setDeleteTeamModalState}
submitLabel={t('settings.teams.deleteTeam')}
handleSubmit={confirmDeleteTeam}
handleCancel={() => setTeamToDelete(null)}
variant="danger"
/>
<ConfirmationModal
message={t('settings.teams.removeMemberConfirmation')}
modalState={removeMemberModalState}
setModalState={setRemoveMemberModalState}
submitLabel={t('settings.teams.remove')}
handleSubmit={confirmRemoveMember}
handleCancel={() => setMemberToRemove(null)}
variant="danger"
/>
</div>
);
}
+37 -1
View File
@@ -1,4 +1,4 @@
import { RefreshCcw, Search as SearchIcon, Trash } from 'lucide-react';
import { RefreshCcw, Search as SearchIcon, Trash, Users } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,6 +27,7 @@ import ConfirmationModal from '../modals/ConfirmationModal';
import MCPServerModal from '../modals/MCPServerModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import ShareToTeamModal from '../teams/ShareToTeamModal';
import RemoteDeviceConfig from './RemoteDeviceConfig';
import ToolConfig from './ToolConfig';
import { APIToolType, UserToolType } from './types';
@@ -62,6 +63,9 @@ export default function Tools() {
const [reconnectModalState, setReconnectModalState] =
React.useState<ActiveState>('INACTIVE');
const [reconnectTool, setReconnectTool] = React.useState<any>(null);
const [toolToShare, setToolToShare] = React.useState<UserToolType | null>(
null,
);
const [mcpStatuses, setMcpStatuses] = React.useState<{
[toolId: string]: string;
}>({});
@@ -133,6 +137,18 @@ export default function Tools() {
iconHeight: 16,
},
];
// 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, {
icon: Users,
label: t('settings.tools.shareWithTeam'),
onClick: () => setToolToShare(tool),
variant: 'default',
iconWidth: 16,
iconHeight: 16,
});
}
if (tool.name === 'mcp_tool') {
options.splice(1, 0, {
icon: RefreshCcw,
@@ -435,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
@@ -500,6 +528,14 @@ export default function Tools() {
fetchMcpStatuses();
}}
/>
{toolToShare && (
<ShareToTeamModal
resourceType="tool"
resourceId={toolToShare.id}
resourceName={toolToShare.customName || toolToShare.displayName}
onClose={() => setToolToShare(null)}
/>
)}
</div>
)}
</div>
+6
View File
@@ -87,6 +87,12 @@ export type UserToolType = {
// from the Add-Tool modal; surfaced to the agent picker. May coexist
// with ``default`` for dual-registered tools.
builtin?: boolean;
// 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;
};
+2
View File
@@ -15,6 +15,7 @@ import {
prefListenerMiddleware,
prefSlice,
} from './preferences/preferenceSlice';
import teamsReducer from './teams/teamsSlice';
import uploadReducer from './upload/uploadSlice';
const key = localStorage.getItem('DocsGPTApiKey');
@@ -76,6 +77,7 @@ const store = configureStore({
workflowPreview: workflowPreviewReducer,
notifications: notificationsReducer,
schedules: schedulesReducer,
teams: teamsReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
+671
View File
@@ -0,0 +1,671 @@
import { Check, ChevronDown } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import teamsService, {
AccessLevel,
ResourceShare,
ResourceType,
TeamMember,
} from '../api/services/teamsService';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../components/ui/command';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
import { Modal } from '../components/ui/modal';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { selectToken } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import { decodeJwtPayload } from '../utils/jwtUtils';
import { cn } from '@/lib/utils';
import { loadTeams, selectTeams } from './teamsSlice';
type Props = {
resourceType: ResourceType;
resourceId: string;
resourceName?: string;
onClose: () => void;
};
// Member subs (OIDC subs) can be long; there's no display-name endpoint, so we
// truncate the middle for readability while keeping the ends identifiable.
const truncateSub = (sub: string): string =>
sub.length > 24 ? `${sub.slice(0, 12)}…${sub.slice(-8)}` : sub;
// Prefer the member's email for a human-readable label, falling back to the
// truncated sub when no email is on record.
const memberLabel = (member: TeamMember): string =>
member.email && member.email.trim() !== ''
? member.email
: truncateSub(member.user_id);
const initialOf = (label: string): string => {
const trimmed = label.trim();
return trimmed ? trimmed[0].toUpperCase() : '?';
};
// Stable identity for a grant row: team + optional member target.
const shareKey = (share: ResourceShare): string =>
`${share.team_id}:${share.target_user_id ?? ''}`;
type Suggestion =
| { kind: 'team'; key: string; teamId: string; teamName: string }
| {
kind: 'member';
key: string;
teamId: string;
teamName: string;
userId: string;
label: string;
};
export default function ShareToTeamModal({
resourceType,
resourceId,
resourceName,
onClose,
}: Props) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const teams = useSelector(selectTeams);
// The caller's own OIDC sub — you're the owner, so you're excluded from the
// "share with a specific person" suggestions (can't share with yourself).
const currentUserId = useMemo(() => {
const payload = token ? decodeJwtPayload(token) : null;
return typeof payload?.sub === 'string' ? payload.sub : undefined;
}, [token]);
const [shares, setShares] = useState<ResourceShare[]>([]);
const [loadError, setLoadError] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// The access level applied to the next suggestion picked from the combobox.
const [accessLevel, setAccessLevel] = useState<AccessLevel>('viewer');
// Combobox state.
const [pickerOpen, setPickerOpen] = useState(false);
const [query, setQuery] = useState('');
// Per-team member cache (deduped per user). Loaded lazily for People
// suggestions and to resolve per-member labels in the access list.
const [membersByTeam, setMembersByTeam] = useState<
Record<string, TeamMember[]>
>({});
// Team ids with an in-flight members fetch, so a re-run of the fan-out effect
// (e.g. a teams reload) doesn't fire a duplicate request before its cache write.
const membersInFlight = useRef<Set<string>>(new Set());
// Committing = a suggestion pick is in flight (blocks ESC/overlay close).
const [committing, setCommitting] = useState(false);
// Per-row busy keys so one in-flight row doesn't freeze the whole list.
const [busyKeys, setBusyKeys] = useState<Set<string>>(new Set());
// Ref mirror, kept in sync *synchronously* with each setRowBusy call, so a
// reconcile after a settle reads the current busy set rather than a closure-
// stale or not-yet-committed-render value.
const busyKeysRef = useRef<Set<string>>(busyKeys);
const setRowBusy = (key: string, busy: boolean) => {
const next = new Set(busyKeysRef.current);
if (busy) next.add(key);
else next.delete(key);
busyKeysRef.current = next;
setBusyKeys(next);
};
const inFlight = committing || busyKeys.size > 0;
const refreshShares = () => {
setLoadError(false);
return teamsService
.listResourceShares(resourceType, resourceId, token)
.then((r) => {
const server = (r?.shares ?? []) as ResourceShare[];
// Server state is authoritative, but a reconcile triggered by one
// action's settle must not stomp a *different* row whose optimistic
// edit is still in flight. Keep the local row for any key still busy,
// and append still-busy local rows the server hasn't returned yet.
setShares((prev) => {
const busy = busyKeysRef.current;
if (busy.size === 0) return server;
const serverByKey = new Map(server.map((s) => [shareKey(s), s]));
const prevBusy = prev.filter((s) => busy.has(shareKey(s)));
const merged = server.map(
(s) => prevBusy.find((p) => shareKey(p) === shareKey(s)) ?? s,
);
for (const p of prevBusy) {
if (!serverByKey.has(shareKey(p))) merged.push(p);
}
return merged;
});
})
.catch(() => {
// Distinguish a failed load from a genuinely empty list: keep any
// previously known shares and surface an error rather than silently
// rendering "no access" on a fetch failure.
setLoadError(true);
});
};
useEffect(() => {
dispatch(loadTeams({ token }));
refreshShares();
}, [resourceType, resourceId]);
// 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).
const teamIdsKey = useMemo(
() =>
teams
.map((team) => team.id)
.sort()
.join(','),
[teams],
);
// Lazily load every team's members once we have the team list, so People
// suggestions and per-member labels are available. Cached per team, with an
// in-flight guard so a re-run can't duplicate a not-yet-cached fetch.
useEffect(() => {
teams.forEach((team) => {
if (membersByTeam[team.id] || membersInFlight.current.has(team.id))
return;
membersInFlight.current.add(team.id);
teamsService
.listMembers(team.id, token)
.then((r) => {
const members = (r?.members ?? []) as TeamMember[];
// Collapse the per-(role, source) rows the API returns into one
// entry per member, matching the previous modal's dedupe.
const unique = Array.from(
new Map(members.map((m) => [m.user_id, m])).values(),
);
setMembersByTeam((prev) =>
prev[team.id] ? prev : { ...prev, [team.id]: unique },
);
})
.catch(() => {
// A members fetch failure just means fewer People suggestions /
// sub fallbacks for that team; not a hard error for the modal.
})
.finally(() => {
membersInFlight.current.delete(team.id);
});
});
// membersByTeam is intentionally read but omitted from deps: it's only an
// already-cached guard, and including it would re-fire on every cache write.
}, [teamIdsKey, token]);
const teamName = (teamId: string): string =>
teams.find((team) => team.id === teamId)?.name ?? teamId;
// Resolve a member's display label from the loaded cache, falling back to a
// truncated sub when the email isn't known.
const memberDisplay = (teamId: string, userId: string): string => {
const member = (membersByTeam[teamId] ?? []).find(
(m) => m.user_id === userId,
);
return member ? memberLabel(member) : truncateSub(userId);
};
// Set of granted keys (team + optional member) for fast exclusion.
const grantedKeys = useMemo(() => new Set(shares.map(shareKey)), [shares]);
const matches = (haystack: string, q: string): boolean => {
const needle = q.trim().toLowerCase();
if (!needle) return true;
return haystack.toLowerCase().includes(needle);
};
const teamSuggestions = useMemo<Suggestion[]>(
() =>
teams
// Exclude teams already whole-team-shared.
.filter((team) => !grantedKeys.has(`${team.id}:`))
.filter((team) => matches(team.name, query))
.map((team) => ({
kind: 'team' as const,
key: `team:${team.id}`,
teamId: team.id,
teamName: team.name,
})),
[teams, grantedKeys, query],
);
const memberSuggestions = useMemo<Suggestion[]>(() => {
const out: Suggestion[] = [];
teams.forEach((team) => {
// Skip teams already whole-team-shared: their members are covered by the
// team grant, so offering them would only create redundant per-member
// grants. (Whole-team key convention is `${team_id}:`.)
if (grantedKeys.has(`${team.id}:`)) return;
(membersByTeam[team.id] ?? []).forEach((member) => {
// You can't share a resource with yourself — you own it.
if (member.user_id === currentUserId) return;
const key = `${team.id}:${member.user_id}`;
// Exclude (team, member) pairs already granted.
if (grantedKeys.has(key)) return;
const label = memberLabel(member);
if (!matches(`${label} ${member.user_id} ${team.name}`, query)) return;
out.push({
kind: 'member',
key: `member:${key}`,
teamId: team.id,
teamName: team.name,
userId: member.user_id,
label,
});
});
});
return out;
}, [teams, membersByTeam, grantedKeys, query, currentUserId]);
const hasSuggestions =
teamSuggestions.length > 0 || memberSuggestions.length > 0;
// Mutations update `shares` optimistically for instant feedback, then
// reconcile against the server once the request settles: refreshShares() runs
// on BOTH success and failure so server state is authoritative. This avoids
// the pitfalls of a manual revert-by-shareKey (stomping a concurrent change,
// re-adding a duplicate row on remove-failure, clobbering a newer value on
// role-change-failure). Per-row busy keys still gate each row's controls.
// Optimistically commit a picked suggestion, then reconcile with the server.
const commitSuggestion = async (suggestion: Suggestion) => {
setPickerOpen(false);
setQuery('');
setActionError(null);
setCommitting(true);
const optimistic: ResourceShare =
suggestion.kind === 'team'
? {
team_id: suggestion.teamId,
team_name: suggestion.teamName,
access_level: accessLevel,
target_user_id: null,
}
: {
team_id: suggestion.teamId,
team_name: suggestion.teamName,
access_level: accessLevel,
target_user_id: suggestion.userId,
};
setShares((prev) => [...prev, optimistic]);
try {
await teamsService.share(
suggestion.teamId,
{
resource_type: resourceType,
resource_id: resourceId,
access_level: accessLevel,
target_user_id:
suggestion.kind === 'member' ? suggestion.userId : undefined,
},
token,
);
} catch {
setActionError(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
// preserving any other row whose optimistic edit is still in flight).
setCommitting(false);
// Reconcile against the server on success and failure alike: a successful
// grant gets its canonical row, a failed one drops the optimistic row.
await refreshShares();
}
};
// Optimistically change a grant's access level (upsert, last-write-wins),
// then reconcile with the server.
const changeAccess = async (share: ResourceShare, level: AccessLevel) => {
if (share.access_level === level) return;
const key = shareKey(share);
setActionError(null);
setRowBusy(key, true);
setShares((prev) =>
prev.map((s) =>
shareKey(s) === key ? { ...s, access_level: level } : s,
),
);
try {
await teamsService.share(
share.team_id,
{
resource_type: resourceType,
resource_id: resourceId,
access_level: level,
target_user_id: share.target_user_id ?? undefined,
},
token,
);
} catch {
setActionError(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.
setRowBusy(key, false);
await refreshShares();
}
};
// Optimistically remove a grant, then reconcile with the server.
const removeAccess = async (share: ResourceShare) => {
const key = shareKey(share);
setActionError(null);
setRowBusy(key, true);
setShares((prev) => prev.filter((s) => shareKey(s) !== key));
try {
await teamsService.unshare(
share.team_id,
{
resource_type: resourceType,
resource_id: resourceId,
target_user_id: share.target_user_id ?? undefined,
},
token,
);
} catch {
setActionError(t('settings.teams.share.unshareError'));
} finally {
setRowBusy(key, false);
await refreshShares();
}
};
const title = resourceName
? t('settings.teams.share.titleNamed', { name: resourceName })
: t('settings.teams.share.titleGeneric', {
type: t(`settings.teams.resourceType.${resourceType}`).toLowerCase(),
});
const renderRoleControl = (share: ResourceShare) => {
const key = shareKey(share);
const rowBusy = busyKeys.has(key);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={rowBusy}
className="shrink-0 gap-1 px-2 font-normal"
aria-label={t('settings.teams.share.access')}
>
{t(`settings.teams.share.accessLevel.${share.access_level}`)}
<ChevronDown className="size-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
{/* z-200 keeps the menu above Modal (z-50), matching the combobox/access controls. */}
<DropdownMenuContent align="end" className="z-200 min-w-40">
{(['viewer', 'editor'] as AccessLevel[]).map((level) => (
<DropdownMenuItem
key={level}
onSelect={() => changeAccess(share, level)}
>
<Check
className={cn(
'size-4 shrink-0',
share.access_level === level ? 'opacity-100' : 'opacity-0',
)}
/>
{t(`settings.teams.share.accessLevel.${level}`)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => removeAccess(share)}
>
{t('settings.teams.share.removeAccess')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const renderShareRow = (share: ResourceShare) => {
const isTeam = !share.target_user_id;
const name = share.team_name ?? teamName(share.team_id);
const primary = isTeam
? name
: memberDisplay(share.team_id, share.target_user_id!);
const secondary = isTeam
? t('settings.teams.share.teamLabel')
: t('settings.teams.share.viaTeam', { team: name });
return (
<li key={shareKey(share)} className="flex items-center gap-3 py-2">
<Avatar
alt=""
className={cn(
'bg-primary/10 text-primary dark:bg-primary/20 flex size-9 items-center justify-center text-sm font-medium',
isTeam ? 'rounded-md' : 'rounded-full',
)}
>
{initialOf(primary)}
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium" title={primary}>
{primary}
</p>
<p
className="text-muted-foreground truncate text-xs"
title={secondary}
>
{secondary}
</p>
</div>
{renderRoleControl(share)}
</li>
);
};
return (
<Modal
open
onOpenChange={(open) => {
// Don't allow ESC / overlay close while a request is in flight.
if (!open && !inFlight) onClose();
}}
isPerformingTask={inFlight}
title={title}
size="md"
footer={
<Button variant="outline" disabled={inFlight} onClick={onClose}>
{t('settings.teams.share.done')}
</Button>
}
>
<p className="text-muted-foreground mt-1 text-sm">
{t('settings.teams.share.subtitle')}
</p>
{loadError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{t('settings.teams.share.loadError')}
</p>
)}
{actionError && (
<p className="text-destructive mt-3 text-sm" role="alert">
{actionError}
</p>
)}
{teams.length === 0 ? (
<p className="mt-6 text-sm">{t('settings.teams.share.noTeams')}</p>
) : (
<>
{/* Add row: type-ahead combobox + access level select. */}
<div className="mt-4 flex items-center gap-2">
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={pickerOpen}
disabled={committing}
className="text-muted-foreground h-9 min-w-0 flex-1 justify-start gap-2 px-3 font-normal"
>
<span className="truncate">
{t('settings.teams.share.addPlaceholder')}
</span>
</Button>
</PopoverTrigger>
{/* z-200 keeps the popover above Modal (z-50). */}
<PopoverContent
className="z-200 w-[min(22rem,calc(100vw-2rem))] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t('settings.teams.share.searchPlaceholder')}
value={query}
onValueChange={setQuery}
/>
<CommandList>
{!hasSuggestions && (
<CommandEmpty>
{t('settings.teams.share.noMatches')}
</CommandEmpty>
)}
{teamSuggestions.length > 0 && (
<CommandGroup
heading={t('settings.teams.share.teamsGroup')}
>
{teamSuggestions.map((suggestion) => (
<CommandItem
key={suggestion.key}
value={suggestion.key}
onSelect={() => commitSuggestion(suggestion)}
className="gap-3"
>
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-7 items-center justify-center rounded-md text-xs font-medium"
>
{initialOf(suggestion.teamName)}
</Avatar>
<span className="min-w-0 flex-1 truncate">
{suggestion.teamName}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{memberSuggestions.length > 0 && (
<CommandGroup
heading={t('settings.teams.share.peopleGroup')}
>
{memberSuggestions.map((suggestion) =>
suggestion.kind === 'member' ? (
<CommandItem
key={suggestion.key}
value={`${suggestion.key} ${suggestion.label} ${suggestion.teamName}`}
onSelect={() => commitSuggestion(suggestion)}
className="gap-3"
>
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-7 items-center justify-center rounded-full text-xs font-medium"
>
{initialOf(suggestion.label)}
</Avatar>
<span className="min-w-0 flex-1 truncate">
{suggestion.label}
<span className="text-muted-foreground">
{' · '}
{suggestion.teamName}
</span>
</span>
</CommandItem>
) : null,
)}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Select
value={accessLevel}
disabled={committing}
onValueChange={(value) => setAccessLevel(value as AccessLevel)}
>
<SelectTrigger
className="h-9 w-28 shrink-0"
aria-label={t('settings.teams.share.access')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">
{t('settings.teams.share.accessLevel.viewer')}
</SelectItem>
<SelectItem value="editor">
{t('settings.teams.share.accessLevel.editor')}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* People with access. */}
<section className="mt-6">
<h3 className="text-sm font-medium">
{t('settings.teams.share.peopleWithAccess')}
</h3>
<ul className="mt-1 max-h-72 overflow-auto">
{/* Owner — pinned, non-interactive. */}
<li className="flex items-center gap-3 py-2">
<Avatar
alt=""
className="bg-primary/10 text-primary dark:bg-primary/20 flex size-9 items-center justify-center rounded-full text-sm font-medium"
>
{initialOf(t('settings.teams.share.you'))}
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t('settings.teams.share.you')}
</p>
</div>
<span className="text-muted-foreground shrink-0 pr-3 text-sm">
{t('settings.teams.share.owner')}
</span>
</li>
{shares.map(renderShareRow)}
</ul>
</section>
</>
)}
</Modal>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import {
Building2,
Check,
ChevronsUpDown,
Plus,
Settings as SettingsIcon,
Users,
} from 'lucide-react';
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
import { selectToken } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import {
loadTeams,
selectCurrentTeamId,
selectTeams,
setCurrentTeam,
} from './teamsSlice';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../components/ui/dropdown-menu';
type TeamSwitcherProps = {
// Called after a navigation/selection so callers (e.g. the mobile nav) can
// close themselves. Optional.
onNavigate?: () => void;
// Icon-only trigger for the collapsed sidebar (no label/chevron).
collapsed?: boolean;
};
/**
* Brand header that doubles as the team/workspace switcher.
*
* The whole row is the dropdown trigger and morphs its identity: in a
* personal context it shows the DocsGPT logo + wordmark; inside a team it
* shows an initial avatar + the team name. The dropdown lets the user
* quick-switch teams, jump to team management, or create a new team.
* Switching is a UX-only selection persisted in the teams slice via
* setCurrentTeam; it does not scope app data.
*/
export default function TeamSwitcher({
onNavigate,
collapsed = false,
}: TeamSwitcherProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const token = useSelector(selectToken);
const teams = useSelector(selectTeams);
const currentTeamId = useSelector(selectCurrentTeamId);
// Populate the switcher on mount so it works before visiting settings.
useEffect(() => {
dispatch(loadTeams({ token }));
}, [dispatch, token]);
const safeTeams = teams ?? [];
const currentTeam =
safeTeams.find((team) => team.id === currentTeamId) ?? null;
const currentRoleLabel = currentTeam
? currentTeam.member_role === 'team_admin'
? t('teams.switcher.roleAdmin')
: t('teams.switcher.roleMember')
: t('teams.switcher.personalSubtitle');
const currentName = currentTeam
? currentTeam.name
: t('teams.switcher.personal');
// The header trigger shows the brand ("DocsGPT") in a personal context rather
// than "Personal account" — the dropdown still labels the switch entry
// "Personal account".
const triggerLabel = currentTeam ? currentTeam.name : 'DocsGPT';
const teamInitial = currentTeam
? currentTeam.name.charAt(0).toUpperCase()
: '';
// The morphing brand identity shown in the trigger: the DocsGPT logo for a
// personal context, or an initial avatar for a team.
const triggerIcon = currentTeam ? (
// A solid square reads heavier than the dino, so keep the team avatar a
// touch smaller (with a little margin to align with the wordmark).
<span className="bg-muted dark:bg-accent text-foreground mx-1 flex size-7 shrink-0 items-center justify-center rounded-md text-sm font-semibold">
{teamInitial}
</span>
) : (
<img className="h-9 shrink-0" src={DocsGPT3} alt="DocsGPT Logo" />
);
// Open the active team's detail directly (not just the list).
const goToManage = () => {
navigate('/teams', { state: { openTeamId: currentTeam?.id } });
onNavigate?.();
};
// Land on /teams with the create modal already open.
const goToCreate = () => {
navigate('/teams', { state: { create: true } });
onNavigate?.();
};
const selectTeam = (teamId: string | null) => {
dispatch(setCurrentTeam(teamId));
onNavigate?.();
};
// Teams other than the currently active one, for the "switch to" list.
const otherTeams = safeTeams.filter((team) => team.id !== currentTeamId);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{collapsed ? (
<button
type="button"
aria-label={t('teams.switcher.ariaLabel')}
className="hover:bg-muted dark:hover:bg-accent flex items-center justify-center rounded-lg p-1 transition-colors"
>
{triggerIcon}
</button>
) : (
<button
type="button"
aria-label={t('teams.switcher.ariaLabel')}
className="hover:bg-muted dark:hover:bg-accent text-foreground flex w-full items-center gap-1.5 rounded-lg px-2 py-1 text-left transition-colors"
>
{triggerIcon}
<span className="text-foreground min-w-0 flex-1 truncate text-xl font-semibold dark:text-white">
{triggerLabel}
</span>
<ChevronsUpDown
className="text-muted-foreground size-4 shrink-0"
strokeWidth={1.75}
/>
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[248px]">
<DropdownMenuLabel className="flex items-center gap-2">
<span className="bg-muted dark:bg-accent flex size-7 shrink-0 items-center justify-center rounded-md">
{currentTeam ? (
<Users className="size-4" strokeWidth={1.75} />
) : (
<Building2 className="size-4" strokeWidth={1.75} />
)}
</span>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate text-sm font-semibold">
{currentName}
</span>
<span className="text-muted-foreground block truncate text-xs font-normal">
{currentRoleLabel}
</span>
</span>
</DropdownMenuLabel>
{currentTeam && (
<DropdownMenuItem onSelect={goToManage}>
<SettingsIcon className="size-4" strokeWidth={1.75} />
<span>{t('teams.switcher.manageTeam')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Personal account entry */}
<DropdownMenuItem onSelect={() => selectTeam(null)}>
<Building2 className="size-4" strokeWidth={1.75} />
<span className="min-w-0 flex-1 truncate">
{t('teams.switcher.personal')}
</span>
{!currentTeam && <Check className="size-4 shrink-0" />}
</DropdownMenuItem>
{/* Other teams to switch to */}
{otherTeams.map((team) => (
<DropdownMenuItem key={team.id} onSelect={() => selectTeam(team.id)}>
<Users className="size-4" strokeWidth={1.75} />
<span className="min-w-0 flex-1 truncate" title={team.name}>
{team.name}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={goToCreate}>
<Plus className="size-4" strokeWidth={1.75} />
<span>{t('teams.switcher.createTeam')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import teamsService, { TeamRole } from '../api/services/teamsService';
export type Team = {
id: string;
name: string;
slug: string;
description?: string | null;
owner_id: string;
member_role?: TeamRole;
// Annotations from the list endpoint for the teams grid cards.
member_count?: number;
shared_count?: number;
};
export type TeamsState = {
teams: Team[];
// Persisted UX-only selection (NEVER trusted for authorization — the
// backend resolves team roles live from team_id in the URL path).
currentTeamId: string | null;
loading: boolean;
error: string | null;
};
const STORED_CURRENT = localStorage.getItem('currentTeamId');
const initialState: TeamsState = {
teams: [],
currentTeamId: STORED_CURRENT || null,
loading: false,
error: null,
};
export const loadTeams = createAsyncThunk<Team[], { token: string | null }>(
'teams/load',
async ({ token }) => {
const r = await teamsService.list(token);
return (r?.teams as Team[]) ?? [];
},
);
export const createTeam = createAsyncThunk<
Team,
{ name: string; description?: string; token: string | null }
>('teams/create', async ({ name, description, token }) => {
const r = await teamsService.create({ name, description }, token);
// apiClient resolves on any HTTP status, so a backend error parses to
// { success:false, message }. Reject so .unwrap() throws (modal keeps its
// error open) and we never push an undefined team into the list.
if (!r || r.success === false || !r.team) {
throw new Error(r?.message ?? 'Failed to create team');
}
return r.team as Team;
});
export const deleteTeam = createAsyncThunk<
string,
{ id: string; token: string | null }
>('teams/delete', async ({ id, token }) => {
await teamsService.remove(id, token);
return id;
});
const teamsSlice = createSlice({
name: 'teams',
initialState,
reducers: {
setCurrentTeam(state, action: PayloadAction<string | null>) {
state.currentTeamId = action.payload;
if (action.payload) localStorage.setItem('currentTeamId', action.payload);
else localStorage.removeItem('currentTeamId');
},
},
extraReducers: (builder) => {
builder
.addCase(loadTeams.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loadTeams.fulfilled, (state, action) => {
state.loading = false;
state.teams = action.payload;
// Drop a stale currentTeamId the user is no longer a member of.
if (
state.currentTeamId &&
!action.payload.some((t) => t.id === state.currentTeamId)
) {
state.currentTeamId = null;
localStorage.removeItem('currentTeamId');
}
})
.addCase(loadTeams.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Failed to load teams';
})
.addCase(createTeam.fulfilled, (state, action) => {
state.teams.push(action.payload);
})
.addCase(deleteTeam.fulfilled, (state, action) => {
state.teams = state.teams.filter((t) => t.id !== action.payload);
if (state.currentTeamId === action.payload) {
state.currentTeamId = null;
localStorage.removeItem('currentTeamId');
}
});
},
});
export const { setCurrentTeam } = teamsSlice.actions;
export default teamsSlice.reducer;
export const selectTeams = (state: { teams: TeamsState }) => state.teams.teams;
export const selectCurrentTeamId = (state: { teams: TeamsState }) =>
state.teams.currentTeamId;
export const selectTeamsLoading = (state: { teams: TeamsState }) =>
state.teams.loading;
export const selectTeamsError = (state: { teams: TeamsState }) =>
state.teams.error;
+31
View File
@@ -1187,6 +1187,37 @@ class TestToolExecutorExecute:
tool_config = call_kwargs[1]["tool_config"] if "tool_config" in call_kwargs[1] else call_kwargs[0][1]
assert "api_key" in tool_config.get("auth_credentials", tool_config)
def test_get_or_load_tool_decrypts_with_tool_owner(self, monkeypatch):
"""Team-shared tool: credentials decrypt with the OWNER's sub, not the
invoker's (teams OQ2 — delegation). The tool row carries user_id=owner
while the executor runs as a different member."""
executor = ToolExecutor(user="member_bob")
mock_tm = Mock()
mock_tm.load_tool.return_value = Mock()
monkeypatch.setattr(
"application.agents.tool_executor.ToolManager", lambda config: mock_tm
)
captured = {}
def _fake_decrypt(creds, user):
captured["user"] = user
return {"api_key": "owner_secret"}
monkeypatch.setattr(
"application.agents.tool_executor.decrypt_credentials", _fake_decrypt
)
tool_data = {
"id": "00000000-0000-0000-0000-000000000009",
"name": "custom_tool",
"user_id": "owner_alice",
"config": {"encrypted_credentials": "blob"},
}
executor._get_or_load_tool(tool_data, "t9", "act")
# Decrypted under the tool owner, NOT the invoking member.
assert captured["user"] == "owner_alice"
def test_get_or_load_tool_mcp_tool(self, monkeypatch):
"""Cover lines 281-283: mcp_tool path sets query_mode."""
executor = ToolExecutor(user="test_user")
+279
View File
@@ -0,0 +1,279 @@
"""Endpoint tests for agent team-sharing paths.
Covers the GET /api/get_agent read/fallback + name-resolution paths and the
PUT /api/update_agent team-editor save-gate.
"""
from __future__ import annotations
import json
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_details=None):
if source_details is None:
source_details = []
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.agents.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("application.api.user.agents.routes.team_access_for", return_value=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(
"application.api.user.agents.routes.resolve_prompt_name",
return_value=prompt_name,
),
patch(
"application.api.user.agents.routes.resolve_source_details",
return_value=source_details,
),
]
def _run(patches, client, agent_id):
for p in patches:
p.start()
try:
return client.get(f"/api/get_agent?id={agent_id}")
finally:
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestGetAgentTeamFallback:
def test_owner_sees_own_agent(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = {"id": aid, "name": "Mine", "status": "published"}
resp = _run(_patches("alice", repo, None), client, aid)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["team_access"] is None
repo.get_by_id.assert_not_called()
def test_team_member_sees_shared_agent(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = {
"id": aid,
"name": "Shared",
"status": "published",
# Owner secrets that must NOT leak to a team grantee.
"shared_token": "secret-public-token",
"key": "agentkey1234567890",
}
resp = _run(_patches("bob", repo, "viewer"), client, aid)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "team"
assert data["team_access"] == "viewer"
# 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.
repo.get_by_id.assert_called_once_with(aid)
def test_no_access_returns_404(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
resp = _run(_patches("stranger", repo, None), client, aid)
assert resp.status_code == 404
repo.get_by_id.assert_not_called()
@pytest.mark.unit
class TestGetAgentResolvesNames:
"""GET /api/get_agent embeds owner-agnostic prompt/source names so a team
grantee sees the owner's prompt name + source names rather than a blank
prompt / "External KB" (the client can't resolve the owner's resources)."""
def test_team_member_payload_has_resolved_names(self, client):
aid = str(uuid.uuid4())
src = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = {
"id": aid,
"name": "Shared",
"status": "published",
"prompt_id": str(uuid.uuid4()),
"source_id": src,
}
details = [{"id": src, "name": "Owner KB"}]
resp = _run(
_patches(
"bob", repo, "editor",
prompt_name="Owner Prompt", source_details=details,
),
client,
aid,
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "team"
assert data["prompt_name"] == "Owner Prompt"
assert data["source_details"] == details
def test_owner_payload_also_has_resolved_names(self, client):
aid = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = {
"id": aid,
"name": "Mine",
"status": "published",
"prompt_id": "default",
}
resp = _run(
_patches("alice", repo, None, prompt_name="Default", source_details=[]),
client,
aid,
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["prompt_name"] == "Default"
assert data["source_details"] == []
def _update_patches(sub, repo, team_access, can_access_mock):
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.agents.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("application.api.user.agents.routes.team_access_for", return_value=team_access),
patch("application.api.user.agents.routes.can_access", can_access_mock),
]
def _run_update(patches, client, agent_id, body):
for p in patches:
p.start()
try:
return client.put(f"/api/update_agent/{agent_id}", json=body)
finally:
for p in reversed(patches):
p.stop()
def _shared_agent_row(aid, owner_src, owner_prompt):
return {
"id": aid,
"name": "Shared",
"description": "desc",
"status": "draft",
"source_id": owner_src,
"extra_source_ids": [],
"prompt_id": owner_prompt,
"tools": [],
"chunks": 2,
"agent_type": "classic",
"image": "",
}
@pytest.mark.unit
class TestUpdateAgentEditorSaveGate:
"""PUT /api/update_agent: a team EDITOR may save an agent while keeping the
owner's existing source/prompt (which aren't independently shared with
them), but must not ATTACH a new source/prompt they can't access."""
def test_editor_keeps_owner_source_and_prompt(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
repo = Mock()
repo.get_any.return_value = None # not the owner
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
# Editor holds NO independent grant on the source/prompt themselves.
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{
"name": "Shared",
"description": "desc",
"source": owner_src,
"prompt_id": owner_prompt,
},
)
assert resp.status_code == 200
# Unchanged refs are exempt — the access check is never consulted.
can_access.assert_not_called()
repo.update_by_id.assert_called_once()
def test_editor_cannot_attach_new_unshared_source(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
new_src = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{"name": "Shared", "description": "desc", "sources": [new_src]},
)
assert resp.status_code == 403
assert "Source not accessible" in json.loads(resp.data)["message"]
repo.update_by_id.assert_not_called()
def test_editor_cannot_attach_new_unshared_prompt(self, client):
aid, owner_src, owner_prompt = (
str(uuid.uuid4()),
str(uuid.uuid4()),
str(uuid.uuid4()),
)
new_prompt = str(uuid.uuid4())
repo = Mock()
repo.get_any.return_value = None
repo.get_by_id.return_value = _shared_agent_row(aid, owner_src, owner_prompt)
repo.update_by_id.return_value = {"id": aid}
can_access = Mock(return_value=False)
resp = _run_update(
_update_patches("editor-bob", repo, "editor", can_access),
client,
aid,
{
"name": "Shared",
"description": "desc",
"source": owner_src,
"prompt_id": new_prompt,
},
)
assert resp.status_code == 403
assert "Prompt not accessible" in json.loads(resp.data)["message"]
repo.update_by_id.assert_not_called()
+570
View File
@@ -0,0 +1,570 @@
"""Endpoint authorization tests for the teams API.
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
correctness lives in tests/storage/db/repositories/test_teams.py.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _auth(sub="u1", roles=("user",), team_role=None):
"""Patch the chokepoint auth + the team_authz membership resolution.
``team_role`` is what TeamMembersRepository.role_for returns inside
team_authz (None = not a member).
"""
members = Mock()
members.role_for.return_value = team_role
return [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=list(roles)),
patch("application.api.user.team_authz.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.team_authz.TeamMembersRepository", return_value=members),
]
def _apply(patches):
for p in patches:
p.start()
def _stop(patches):
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestTeamCreation:
def test_unauthenticated_401(self, client):
with patch("application.app.handle_auth", return_value=None):
resp = client.post("/api/teams", json={"name": "Acme"})
assert resp.status_code == 401
def test_self_serve_create_returns_201(self, client):
teams_repo = Mock()
teams_repo.slug_exists.return_value = False
teams_repo.create.return_value = {"id": "t1", "name": "Acme", "slug": "acme"}
members_repo = Mock()
patches = _auth(sub="alice") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post("/api/teams", json={"name": "Acme"})
finally:
_stop(patches)
assert resp.status_code == 201
# Creator is added as team_admin in the same flow.
args, kwargs = members_repo.add_member.call_args
assert kwargs.get("role") == "team_admin" or "team_admin" in args
def test_missing_name_400(self, client):
patches = _auth(sub="alice")
_apply(patches)
try:
resp = client.post("/api/teams", json={})
finally:
_stop(patches)
assert resp.status_code == 400
@pytest.mark.unit
class TestTeamAccessControl:
def test_detail_requires_membership(self, client):
patches = _auth(sub="stranger", team_role=None)
_apply(patches)
try:
resp = client.get("/api/teams/team-1")
finally:
_stop(patches)
assert resp.status_code == 403
def test_member_can_read_detail(self, client):
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme", "owner_id": "alice"}
members_repo = Mock()
members_repo.list_members.return_value = []
members_repo.role_for.return_value = "team_member"
patches = _auth(sub="bob", team_role="team_member") + [
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.get("/api/teams/team-1")
finally:
_stop(patches)
assert resp.status_code == 200
def test_team_id_taken_from_path_not_body(self, client):
# Caller is NOT a member of team-1; a forged team_id in the body must be
# ignored — the decorator reads team_id from the URL path only.
patches = _auth(sub="stranger", team_role=None)
_apply(patches)
try:
resp = client.get("/api/teams/team-1", json={"team_id": "team-i-own"})
finally:
_stop(patches)
assert resp.status_code == 403
def test_add_member_requires_team_admin(self, client):
patches = _auth(sub="bob", team_role="team_member")
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members", json={"user_id": "x", "role": "team_member"}
)
finally:
_stop(patches)
assert resp.status_code == 403
def test_team_admin_can_add_member(self, client):
members_repo = Mock()
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members", json={"user_id": "carol", "role": "team_member"}
)
finally:
_stop(patches)
assert resp.status_code == 200
members_repo.set_manual_role.assert_called_once()
def test_add_member_by_email_resolves_to_sub(self, client):
members_repo = Mock()
users_repo = Mock()
users_repo.find_by_email.return_value = {"user_id": "resolved-sub"}
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.UsersRepository",
return_value=users_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"email": "bob@example.com", "role": "team_member"},
)
finally:
_stop(patches)
assert resp.status_code == 200
args, kwargs = members_repo.set_manual_role.call_args
assert "resolved-sub" in args # resolved sub used, not the email
def test_add_member_email_not_found(self, client):
users_repo = Mock()
users_repo.find_by_email.return_value = None
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.UsersRepository",
return_value=users_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"email": "ghost@example.com", "role": "team_member"},
)
finally:
_stop(patches)
assert resp.status_code == 404
def test_delete_team_non_owner_forbidden(self, client):
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "owner_id": "alice"}
patches = _auth(sub="bob", roles=("user",), team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
]
_apply(patches)
try:
resp = client.delete("/api/teams/team-1")
finally:
_stop(patches)
# team_admin is not the owner — only the owner (or a global admin) deletes.
assert resp.status_code == 403
teams_repo.delete.assert_not_called()
@pytest.mark.unit
class TestSharingAuthz:
def test_share_requires_ownership(self, client):
patches = _auth(sub="bob", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=False),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "11111111-1111-1111-1111-111111111111",
},
)
finally:
_stop(patches)
assert resp.status_code == 403
def test_owner_can_share(self, client):
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"access_level": "editor",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
_, kwargs = grants_repo.grant.call_args
assert kwargs.get("access_level") == "editor"
def test_invalid_resource_type_rejected(self, client):
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={"resource_type": "secret_thing", "resource_id": "a1"},
)
finally:
_stop(patches)
assert resp.status_code == 400
def test_share_with_member_validates_membership(self, client):
# target_user_id must be a member of the team.
members_repo = Mock()
members_repo.is_member.return_value = False
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"target_user_id": "stranger",
},
)
finally:
_stop(patches)
assert resp.status_code == 400
def test_share_with_valid_member(self, client):
members_repo = Mock()
members_repo.is_member.return_value = True
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "target_user_id": "bob"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"target_user_id": "bob",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
_, kwargs = grants_repo.grant.call_args
assert kwargs.get("target_user_id") == "bob"
def test_non_uuid_resource_id_rejected(self, client):
# Team grants are UUID-only (post-cutover); a legacy/non-UUID id must be
# rejected cleanly, not cast-and-poison the txn into a generic error.
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={"resource_type": "agent", "resource_id": "legacy-mongo-id"},
)
finally:
_stop(patches)
assert resp.status_code == 400
@pytest.mark.unit
class TestAdminOversight:
def test_non_admin_forbidden(self, client):
patches = _auth(sub="bob", roles=("user",))
_apply(patches)
try:
resp = client.get("/api/admin/teams")
finally:
_stop(patches)
assert resp.status_code == 403
def test_global_admin_lists_all(self, client):
teams_repo = Mock()
teams_repo.list_all.return_value = [{"id": "t1", "member_count": 3}]
patches = _auth(sub="root", roles=("admin", "user")) + [
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.TeamsRepository", return_value=teams_repo),
]
_apply(patches)
try:
resp = client.get("/api/admin/teams")
finally:
_stop(patches)
assert resp.status_code == 200
assert json.loads(resp.data)["teams"][0]["member_count"] == 3
@pytest.mark.unit
class TestTeamNotifications:
"""Best-effort, post-commit toasts via publish_user_event."""
def test_add_member_notifies_new_member(self, client):
members_repo = Mock()
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme"}
publish = Mock()
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamsRepository",
return_value=teams_repo,
),
patch("application.api.user.teams.routes.publish_user_event", publish),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"user_id": "carol", "role": "team_member"},
)
finally:
_stop(patches)
assert resp.status_code == 200
publish.assert_called_once()
args, _ = publish.call_args
assert args[0] == "carol"
assert args[1] == "team.member_added"
assert args[2]["team_name"] == "Acme"
assert args[2]["role"] == "team_member"
assert args[2]["added_by"] == "alice"
def test_add_member_skips_self(self, client):
members_repo = Mock()
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme"}
publish = Mock()
patches = _auth(sub="alice", team_role="team_admin") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamsRepository",
return_value=teams_repo,
),
patch("application.api.user.teams.routes.publish_user_event", publish),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/members",
json={"user_id": "alice", "role": "team_admin"},
)
finally:
_stop(patches)
assert resp.status_code == 200
publish.assert_not_called()
def test_whole_team_share_notifies_all_but_sharer(self, client):
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "access_level": "editor"}
members_repo = Mock()
members_repo.list_members.return_value = [
{"user_id": "alice"},
{"user_id": "bob"},
{"user_id": "carol"},
]
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme"}
publish = Mock()
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamsRepository",
return_value=teams_repo,
),
patch(
"application.api.user.teams.routes._resource_display_name",
return_value="My Agent",
),
patch("application.api.user.teams.routes.publish_user_event", publish),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"access_level": "editor",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
recipients = {call.args[0] for call in publish.call_args_list}
assert recipients == {"bob", "carol"} # never the sharer (alice)
first = publish.call_args_list[0].args
assert first[1] == "resource.shared"
assert first[2]["resource_name"] == "My Agent"
assert first[2]["access_level"] == "editor"
assert first[2]["shared_by"] == "alice"
def test_per_member_share_notifies_only_target(self, client):
grants_repo = Mock()
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
members_repo = Mock()
members_repo.is_member.return_value = True
teams_repo = Mock()
teams_repo.get.return_value = {"id": "team-1", "name": "Acme"}
publish = Mock()
patches = _auth(sub="alice", team_role="team_member") + [
patch("application.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.teams.routes.owns_resource", return_value=True),
patch(
"application.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
),
patch(
"application.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
),
patch(
"application.api.user.teams.routes.TeamsRepository",
return_value=teams_repo,
),
patch(
"application.api.user.teams.routes._resource_display_name",
return_value="My Agent",
),
patch("application.api.user.teams.routes.publish_user_event", publish),
]
_apply(patches)
try:
resp = client.post(
"/api/teams/team-1/grants",
json={
"resource_type": "agent",
"resource_id": "22222222-2222-2222-2222-222222222222",
"access_level": "viewer",
"target_user_id": "bob",
},
)
finally:
_stop(patches)
assert resp.status_code == 201
publish.assert_called_once()
args, _ = publish.call_args
assert args[0] == "bob"
assert args[1] == "resource.shared"
+12 -6
View File
@@ -210,7 +210,9 @@ class TestAddChunk:
response = AddChunk().post()
assert response.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -223,7 +225,7 @@ class TestAddChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 404
assert response.status_code == 403
def test_adds_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import AddChunk
@@ -282,7 +284,9 @@ class TestDeleteChunk:
response = DeleteChunk().delete()
assert response.status_code == 401
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -292,7 +296,7 @@ class TestDeleteChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
assert response.status_code == 404
assert response.status_code == 403
def test_deletes_chunk(self, app, pg_conn):
from application.api.user.sources.chunks import DeleteChunk
@@ -359,7 +363,9 @@ class TestUpdateChunk:
response = UpdateChunk().put()
assert response.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
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".
from application.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -372,7 +378,7 @@ class TestUpdateChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 404
assert response.status_code == 403
def test_returns_404_chunk_not_found(self, app, pg_conn):
from application.api.user.sources.chunks import UpdateChunk
@@ -0,0 +1,97 @@
"""GET /api/sources/paginated surfaces team-shared sources (with ownership/access).
Regression test: the paginated settings list must include sources shared with the
caller's teams — matching the /api/sources dropdown — not just owned sources.
"""
from __future__ import annotations
import json
import uuid
from contextlib import contextmanager
from unittest.mock import Mock, patch
import pytest
@pytest.fixture
def client():
from application.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@contextmanager
def _cm(value):
yield value
def _source_row(sid, *, user_id):
return {
"id": sid,
"name": f"src-{sid[:4]}",
"user_id": user_id,
"date": "",
"tokens": "",
"retriever": "classic",
"sync_frequency": "",
"remote_data": None,
"directory_structure": None,
"type": "file",
"ingest_status": None,
}
def _run(sub, repo, team_shared, client):
patches = [
patch("application.app.handle_auth", return_value={"sub": sub}),
patch("application.app.resolve_roles", return_value=["user"]),
patch("application.api.user.sources.routes.db_readonly", lambda: _cm(Mock())),
patch("application.api.user.sources.routes.SourcesRepository", return_value=repo),
patch(
"application.api.user.sources.routes.visible_with_access",
return_value=team_shared,
),
]
for p in patches:
p.start()
try:
return client.get("/api/sources/paginated")
finally:
for p in reversed(patches):
p.stop()
@pytest.mark.unit
class TestPaginatedSourcesTeamSharing:
def test_team_shared_source_appears_with_access(self, client):
owned, shared = str(uuid.uuid4()), str(uuid.uuid4())
repo = Mock()
repo.count_for_user.return_value = 2
repo.list_for_user.return_value = [
_source_row(owned, user_id="bob"),
_source_row(shared, user_id="alice"), # not the caller → team-shared
]
resp = _run("bob", repo, {shared: "editor"}, client)
assert resp.status_code == 200
by_id = {d["id"]: d for d in json.loads(resp.data)["paginated"]}
assert by_id[owned]["ownership"] == "user"
assert by_id[owned]["team_access"] is None
assert by_id[shared]["ownership"] == "team"
assert by_id[shared]["team_access"] == "editor"
# 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]
def test_no_shares_returns_only_owned(self, client):
owned = str(uuid.uuid4())
repo = Mock()
repo.count_for_user.return_value = 1
repo.list_for_user.return_value = [_source_row(owned, user_id="bob")]
resp = _run("bob", repo, {}, client)
assert resp.status_code == 200
docs = json.loads(resp.data)["paginated"]
assert len(docs) == 1
assert docs[0]["ownership"] == "user"
assert repo.list_for_user.call_args.kwargs["extra_ids"] == []
+60 -4
View File
@@ -512,7 +512,9 @@ class TestSyncSource:
response = SyncSource().post()
assert response.status_code == 400
def test_returns_404_missing_source(self, app, pg_conn):
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".
from application.api.user.sources.routes import SyncSource
with _patch_db(pg_conn), app.test_request_context(
@@ -523,7 +525,7 @@ class TestSyncSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = SyncSource().post()
assert response.status_code == 404
assert response.status_code == 403
def test_returns_400_for_connector_type(self, app, pg_conn):
from application.api.user.sources.routes import SyncSource
@@ -659,7 +661,9 @@ class TestReingestSource:
response = ReingestSource().post()
assert response.status_code == 400
def test_returns_404_missing_source(self, app, pg_conn):
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".
from application.api.user.sources.routes import ReingestSource
with _patch_db(pg_conn), app.test_request_context(
@@ -670,7 +674,7 @@ class TestReingestSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = ReingestSource().post()
assert response.status_code == 404
assert response.status_code == 403
def test_triggers_reingest_task(self, app, pg_conn):
from application.api.user.sources.routes import ReingestSource
@@ -701,6 +705,58 @@ class TestReingestSource:
f"reingest-source:{user}:{src['id']}"
)
def test_team_editor_reingests_as_owner(self, app, pg_conn):
"""A team editor (not the owner) can reingest a shared source; the
task dispatches AS the real owner so the owner-scoped pipeline and
the owner-agnostic vector partition stay consistent.
"""
import uuid
from application.api.user.sources.routes import ReingestSource
from application.storage.db.repositories.team_members import (
TeamMembersRepository,
)
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
owner = "alice-reingest"
editor = "bob-reingest"
src = _seed_source(pg_conn, owner, name="shared-src", type="file")
sid = str(src["id"])
team = TeamsRepository(pg_conn).create(
"Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
)
TeamMembersRepository(pg_conn).add_member(
team["id"], editor, role="team_member"
)
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", sid, owner_id=owner, granted_by=owner,
access_level="editor",
)
fake_task = MagicMock(id="reingest-task-editor")
with _patch_db(pg_conn), patch(
"application.api.user.sources.routes.reingest_source_task.delay",
return_value=fake_task,
) as mock_delay, app.test_request_context(
"/api/sources/reingest",
method="POST",
json={"source_id": sid},
):
from flask import request
request.decoded_token = {"sub": editor}
response = ReingestSource().post()
assert response.status_code == 200
assert mock_delay.call_args.kwargs["source_id"] == sid
# Dispatched AS the owner, not the editor caller.
assert mock_delay.call_args.kwargs["user"] == owner
assert mock_delay.call_args.kwargs["idempotency_key"] == (
f"reingest-source:{owner}:{sid}"
)
def test_clears_stalled_ingest_progress_row(self, app, pg_conn):
"""Reingest drops the stale chunk-progress row so the sources
list stops deriving a 'failed' ingest status for the source.
@@ -0,0 +1,141 @@
"""Integration tests for agent team-sharing: repo methods + sharing helpers.
Exercises the real ``team_sharing`` helpers and the new AgentsRepository
methods (get_by_id / list_by_ids / update_by_id) against a real schema.
"""
from __future__ import annotations
import uuid
from application.api.user.team_sharing import (
can_access,
effective_write_owner,
owns_resource,
team_access_for,
visible_with_access,
)
from application.storage.db.repositories.team_scope import TeamScopeRepository
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.team_members import TeamMembersRepository
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.teams import TeamsRepository
def _team(conn, owner="alice"):
return TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
def _agent(conn, owner="alice", name="A"):
return AgentsRepository(conn).create(owner, name, "published")
class TestAgentRepoTeamMethods:
def test_get_by_id_ignores_owner(self, pg_conn):
repo = AgentsRepository(pg_conn)
agent = _agent(pg_conn, owner="alice")
# get_by_id has no user scoping — only call after a grant check.
assert repo.get_by_id(str(agent["id"]))["id"] == str(agent["id"])
# get_any IS owner-scoped: bob can't reach alice's agent.
assert repo.get_any(str(agent["id"]), "bob") is None
def test_list_by_ids(self, pg_conn):
repo = AgentsRepository(pg_conn)
a1 = _agent(pg_conn, name="one")
a2 = _agent(pg_conn, name="two")
ids = {str(a1["id"]), str(a2["id"])}
listed = {str(a["id"]) for a in repo.list_by_ids(list(ids))}
assert listed == ids
assert repo.list_by_ids([]) == []
def test_update_by_id_optimistic_lock(self, pg_conn):
repo = AgentsRepository(pg_conn)
agent = _agent(pg_conn)
aid = str(agent["id"])
ts = repo.get_by_id(aid)["updated_at"]
# Matching version → applied.
assert repo.update_by_id(aid, {"name": "renamed"}, expected_updated_at=ts) is True
assert repo.get_by_id(aid)["name"] == "renamed"
# Stale version (row exists, no match) → None so the route can 409.
stale = repo.update_by_id(
aid, {"name": "again"}, expected_updated_at="2000-01-01T00:00:00+00:00"
)
assert stale is None
# No version supplied → unconditional write.
assert repo.update_by_id(aid, {"name": "free"}) is True
class TestSharingHelpers:
def test_owns_resource_dispatch(self, pg_conn):
agent = _agent(pg_conn, owner="alice")
aid = str(agent["id"])
assert owns_resource(pg_conn, "agent", aid, "alice") is True
assert owns_resource(pg_conn, "agent", aid, "bob") is False
# type/id mismatch (an agent id under resource_type 'source') → False.
assert owns_resource(pg_conn, "source", aid, "alice") is False
def test_can_access_owner_and_team_member(self, pg_conn):
team = _team(pg_conn, owner="alice")
agent = _agent(pg_conn, owner="alice")
aid = str(agent["id"])
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
# Before sharing: bob has no access.
assert can_access(pg_conn, "agent", aid, "bob") is False
grants.grant(team["id"], "agent", aid, owner_id="alice", granted_by="alice")
# After sharing: owner always, member via grant, stranger never.
assert can_access(pg_conn, "agent", aid, "alice") is True
assert can_access(pg_conn, "agent", aid, "bob") is True
assert can_access(pg_conn, "agent", aid, "ghost") is False
# Empty reference (clearing a source) is always allowed.
assert can_access(pg_conn, "source", "", "bob") is True
def test_visible_with_access_and_team_access_for(self, pg_conn):
team = _team(pg_conn, owner="alice")
agent = _agent(pg_conn, owner="alice")
aid = str(agent["id"])
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
grants.grant(
team["id"], "agent", aid, owner_id="alice", granted_by="alice", access_level="editor"
)
assert visible_with_access(pg_conn, "bob", "agent") == {aid: "editor"}
assert team_access_for(pg_conn, "bob", "agent", aid) == "editor"
# Owner is not "team-visible" via this path (they own it directly).
assert visible_with_access(pg_conn, "alice", "agent") == {}
class TestWriteOwnerAndGuards:
def test_effective_write_owner_paths(self, pg_conn):
team = _team(pg_conn, owner="alice")
agent = _agent(pg_conn, owner="alice")
aid = str(agent["id"])
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
members.add_member(team["id"], "carol", role="team_member")
# Owner → writes as themselves.
assert effective_write_owner(pg_conn, "agent", aid, "alice") == "alice"
# Viewer grant → no write.
grants.grant(team["id"], "agent", aid, owner_id="alice", granted_by="alice")
assert effective_write_owner(pg_conn, "agent", aid, "bob") is None
# Editor grant → writes AS the real owner (so dual-key update matches).
grants.grant(
team["id"], "agent", aid, owner_id="alice", granted_by="alice", access_level="editor"
)
assert effective_write_owner(pg_conn, "agent", aid, "bob") == "alice"
# Non-member → no write even with an editor grant on the team.
assert effective_write_owner(pg_conn, "agent", aid, "stranger") is None
def test_non_uuid_id_never_crashes(self, pg_conn):
# Legacy/non-UUID ids can't carry a grant and must not poison the txn.
scope = TeamScopeRepository(pg_conn)
assert scope.effective_access("bob", "agent", "legacy-mongo-objectid") is None
assert scope.can_read("bob", "agent", "not-a-uuid") is False
assert effective_write_owner(pg_conn, "agent", "not-a-uuid", "bob") is None
# Connection still usable afterwards (no aborted transaction).
assert _agent(pg_conn, owner="zed")["user_id"] == "zed"
@@ -0,0 +1,51 @@
"""Integration tests for prompts/sources/tools team-sharing repo methods."""
from __future__ import annotations
from application.storage.db.repositories.prompts import PromptsRepository
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.user_tools import UserToolsRepository
class TestPromptsSharing:
def test_get_by_id_and_list_by_ids(self, pg_conn):
repo = PromptsRepository(pg_conn)
p1 = repo.create("alice", "P1", "c1")
p2 = repo.create("alice", "P2", "c2")
# get_for_rendering is the ownerless fetch used by the team fallback.
assert repo.get_for_rendering(str(p1["id"]))["name"] == "P1"
ids = {str(p1["id"]), str(p2["id"])}
assert {str(p["id"]) for p in repo.list_by_ids(list(ids))} == ids
def test_update_by_id_optimistic_lock(self, pg_conn):
repo = PromptsRepository(pg_conn)
p = repo.create("alice", "P", "c")
pid = str(p["id"])
ts = repo.get_for_rendering(pid)["updated_at"]
assert repo.update_by_id(pid, "P2", "c2", expected_updated_at=ts) is True
assert repo.get_for_rendering(pid)["content"] == "c2"
# Stale version → None (route answers 409).
assert repo.update_by_id(pid, "P3", "c3", expected_updated_at="2000-01-01T00:00:00+00:00") is None
# No version → unconditional.
assert repo.update_by_id(pid, "P4", "c4") is True
class TestSourcesSharing:
def test_get_by_id_owner_agnostic(self, pg_conn):
repo = SourcesRepository(pg_conn)
src = repo.create("KB", user_id="alice")
sid = str(src["id"])
assert repo.get_by_id(sid)["id"] == sid
# get_any is owner-scoped.
assert repo.get_any(sid, "bob") is None
assert {str(s["id"]) for s in repo.list_by_ids([sid])} == {sid}
class TestToolsSharing:
def test_get_by_id_owner_agnostic(self, pg_conn):
repo = UserToolsRepository(pg_conn)
tool = repo.create("alice", "api_tool", config={"x": 1}, actions=[])
tid = str(tool["id"])
assert repo.get_by_id(tid)["id"] == tid
assert repo.get_any(tid, "bob") is None
assert {str(t["id"]) for t in repo.list_by_ids([tid])} == {tid}
+318
View File
@@ -0,0 +1,318 @@
"""Tests for the teams repositories against a real Postgres schema.
Covers TeamsRepository, TeamMembersRepository, TeamResourceGrantsRepository,
TeamScopeRepository, and the dangling-grant cleanup trigger from migration 0021.
"""
from __future__ import annotations
import uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.team_members import TeamMembersRepository
from application.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from application.storage.db.repositories.team_scope import TeamScopeRepository
from application.storage.db.repositories.teams import TeamsRepository
from application.storage.db.repositories.users import UsersRepository
def _new_team(conn, name="Acme", slug=None, owner="alice"):
slug = slug or f"acme-{uuid.uuid4().hex[:8]}"
return TeamsRepository(conn).create(name=name, slug=slug, owner_id=owner)
class TestTeamsRepository:
def test_create_get_roundtrip(self, pg_conn):
repo = TeamsRepository(pg_conn)
team = _new_team(pg_conn, slug="acme")
assert team["name"] == "Acme"
assert team["owner_id"] == "alice"
fetched = repo.get(team["id"])
assert fetched["id"] == team["id"]
assert repo.get_by_slug("acme")["id"] == team["id"]
assert repo.slug_exists("acme") is True
assert repo.slug_exists("nope") is False
def test_slug_is_case_insensitive(self, pg_conn):
repo = TeamsRepository(pg_conn)
_new_team(pg_conn, slug="Acme")
# CITEXT slug — lookups are case-insensitive.
assert repo.get_by_slug("acme") is not None
def test_list_for_user_annotates_role(self, pg_conn):
teams = TeamsRepository(pg_conn)
members = TeamMembersRepository(pg_conn)
t1 = _new_team(pg_conn, name="One", owner="alice")
t2 = _new_team(pg_conn, name="Two", owner="bob")
members.add_member(t1["id"], "alice", role="team_admin")
members.add_member(t2["id"], "alice", role="team_member")
rows = {r["name"]: r["member_role"] for r in teams.list_for_user("alice")}
assert rows == {"One": "team_admin", "Two": "team_member"}
def test_list_for_user_empty(self, pg_conn):
assert TeamsRepository(pg_conn).list_for_user("nobody") == []
def test_update_and_reassign_owner(self, pg_conn):
repo = TeamsRepository(pg_conn)
team = _new_team(pg_conn)
assert repo.update(team["id"], {"name": "Renamed", "bogus": "x"}) is True
assert repo.get(team["id"])["name"] == "Renamed"
assert repo.reassign_owner(team["id"], "carol") is True
assert repo.get(team["id"])["owner_id"] == "carol"
def test_delete_cascades_members(self, pg_conn):
teams = TeamsRepository(pg_conn)
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
members.add_member(team["id"], "alice", role="team_admin")
# Keep the side-effecting call off the assert line so it always runs,
# even under `python -O` (asserts stripped).
deleted = teams.delete(team["id"])
assert deleted is True
assert members.list_members(team["id"]) == []
class TestTeamMembers:
def test_role_for_strongest_wins(self, pg_conn):
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
members.add_member(team["id"], "alice", role="team_member")
members.add_member(team["id"], "alice", role="team_admin")
assert members.role_for("alice", team["id"]) == "team_admin"
assert members.is_member("alice", team["id"]) is True
def test_role_for_non_member_is_none(self, pg_conn):
team = _new_team(pg_conn)
assert TeamMembersRepository(pg_conn).role_for("ghost", team["id"]) is None
def test_list_team_ids_for(self, pg_conn):
members = TeamMembersRepository(pg_conn)
t1 = _new_team(pg_conn, name="One")
t2 = _new_team(pg_conn, name="Two")
members.add_member(t1["id"], "alice", role="team_member")
members.add_member(t2["id"], "alice", role="team_member")
assert set(members.list_team_ids_for("alice")) == {t1["id"], t2["id"]}
def test_set_manual_role_demotes(self, pg_conn):
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
members.add_member(team["id"], "alice", role="team_admin")
members.set_manual_role(team["id"], "alice", "team_member")
assert members.role_for("alice", team["id"]) == "team_member"
# Exactly one manual row remains.
rows = [m for m in members.list_members(team["id"]) if m["source"] == "manual"]
assert len(rows) == 1 and rows[0]["role"] == "team_member"
def test_count_admins_and_last_admin_guard_data(self, pg_conn):
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
members.add_member(team["id"], "alice", role="team_admin")
members.add_member(team["id"], "bob", role="team_admin")
assert members.count_admins(team["id"]) == 2
members.remove_member(team["id"], "bob")
assert members.count_admins(team["id"]) == 1
def test_remove_member_scoped_by_source(self, pg_conn):
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
members.add_member(team["id"], "alice", role="team_member", source="manual")
members.add_member(team["id"], "alice", role="team_member", source="oidc_group")
# Removing only the oidc grant keeps the manual one (IdP sync can't wipe manual).
members.remove_member(team["id"], "alice", source="oidc_group")
assert members.is_member("alice", team["id"]) is True
members.remove_member(team["id"], "alice")
assert members.is_member("alice", team["id"]) is False
class TestResourceGrants:
def test_grant_then_list(self, pg_conn):
team = _new_team(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
rid = str(uuid.uuid4())
row = grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
assert row["access_level"] == "viewer"
listed = grants.list_for_team(team["id"], "agent")
assert len(listed) == 1 and listed[0]["resource_id"] == rid
def test_re_share_updates_access_level(self, pg_conn):
team = _new_team(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
grants.grant(
team["id"], "agent", rid, owner_id="alice", granted_by="alice", access_level="editor"
)
assert grants.get(team["id"], "agent", rid)["access_level"] == "editor"
# ON CONFLICT — still exactly one row.
assert len(grants.list_for_team(team["id"], "agent")) == 1
def test_revoke(self, pg_conn):
team = _new_team(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
assert grants.revoke(team["id"], "agent", rid) is True
assert grants.get(team["id"], "agent", rid) is None
def test_list_for_resource_includes_team_name(self, pg_conn):
team = _new_team(pg_conn, name="Acme")
grants = TeamResourceGrantsRepository(pg_conn)
rid = str(uuid.uuid4())
grants.grant(team["id"], "prompt", rid, owner_id="alice", granted_by="alice")
rows = grants.list_for_resource("prompt", rid)
assert rows[0]["team_name"] == "Acme"
class TestTeamScope:
def test_visible_ids_via_membership(self, pg_conn):
team = _new_team(pg_conn)
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
assert scope.visible_resource_ids("bob", "agent") == {rid}
# A non-member sees nothing.
assert scope.visible_resource_ids("ghost", "agent") == set()
# Wrong resource type is filtered out.
assert scope.visible_resource_ids("bob", "source") == set()
def test_revoking_membership_drops_visibility(self, pg_conn):
team = _new_team(pg_conn)
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
members.remove_member(team["id"], "bob")
# Live JOIN — visibility gone immediately, grant row untouched.
assert scope.visible_resource_ids("bob", "agent") == set()
assert grants.get(team["id"], "agent", rid) is not None
def test_effective_access_editor_wins_across_teams(self, pg_conn):
t1 = _new_team(pg_conn, name="One")
t2 = _new_team(pg_conn, name="Two")
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
members.add_member(t1["id"], "bob", role="team_member")
members.add_member(t2["id"], "bob", role="team_member")
rid = str(uuid.uuid4())
grants.grant(t1["id"], "tool", rid, owner_id="alice", granted_by="alice") # viewer
grants.grant(
t2["id"], "tool", rid, owner_id="alice", granted_by="alice", access_level="editor"
)
assert scope.effective_access("bob", "tool", rid) == "editor"
assert scope.can_write("bob", "tool", rid) is True
assert scope.can_read("bob", "tool", rid) is True
# Non-member: no access.
assert scope.effective_access("ghost", "tool", rid) is None
assert scope.can_write("ghost", "tool", rid) is False
class TestPerMemberSharing:
def test_member_grant_visible_only_to_target(self, pg_conn):
team = _new_team(pg_conn)
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
members.add_member(team["id"], "carol", role="team_member")
rid = str(uuid.uuid4())
grants.grant(
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="bob"
)
# Only the targeted member sees a per-member grant.
assert scope.visible_resource_ids("bob", "agent") == {rid}
assert scope.visible_resource_ids("carol", "agent") == set()
assert scope.effective_access("bob", "agent", rid) == "viewer"
assert scope.effective_access("carol", "agent", rid) is None
def test_whole_team_and_member_grants_coexist(self, pg_conn):
team = _new_team(pg_conn)
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
members.add_member(team["id"], "carol", role="team_member")
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice") # whole team
grants.grant(
team["id"], "agent", rid, owner_id="alice", granted_by="alice",
target_user_id="bob", access_level="editor",
)
# Distinct rows (functional dedup over COALESCE(target,'')).
assert len(grants.list_for_team(team["id"], "agent")) == 2
# bob gets the strongest across whole-team + his member grant; carol only viewer.
assert scope.effective_access("bob", "agent", rid) == "editor"
assert scope.effective_access("carol", "agent", rid) == "viewer"
def test_revoke_member_grant_keeps_whole_team(self, pg_conn):
team = _new_team(pg_conn)
members = TeamMembersRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
members.add_member(team["id"], "bob", role="team_member")
rid = str(uuid.uuid4())
grants.grant(team["id"], "agent", rid, owner_id="alice", granted_by="alice")
grants.grant(
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="bob"
)
assert grants.revoke(team["id"], "agent", rid, target_user_id="bob") is True
assert grants.get(team["id"], "agent", rid, target_user_id="bob") is None
# Whole-team grant untouched.
assert grants.get(team["id"], "agent", rid) is not None
def test_member_grant_needs_membership_for_visibility(self, pg_conn):
# A grant targeting a non-member is inert — the live JOIN requires the
# viewer to be a team member.
team = _new_team(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
scope = TeamScopeRepository(pg_conn)
rid = str(uuid.uuid4())
grants.grant(
team["id"], "agent", rid, owner_id="alice", granted_by="alice", target_user_id="ghost"
)
assert scope.visible_resource_ids("ghost", "agent") == set()
class TestEmailLookup:
def test_upsert_stores_and_find_by_email_case_insensitive(self, pg_conn):
users = UsersRepository(pg_conn)
users.upsert("sub-1", email="Alice@Example.com")
row = users.find_by_email("alice@example.com")
assert row is not None and row["user_id"] == "sub-1"
assert users.find_by_email("nobody@example.com") is None
assert users.find_by_email("") is None
def test_upsert_without_email_preserves_existing(self, pg_conn):
users = UsersRepository(pg_conn)
users.upsert("sub-2", email="bob@example.com")
users.upsert("sub-2") # a non-OIDC upsert must not wipe the stored email
assert users.find_by_email("bob@example.com")["user_id"] == "sub-2"
def test_list_members_includes_email(self, pg_conn):
users = UsersRepository(pg_conn)
members = TeamMembersRepository(pg_conn)
team = _new_team(pg_conn)
users.upsert("carol", email="carol@team.com")
members.add_member(team["id"], "carol", role="team_member")
row = next(m for m in members.list_members(team["id"]) if m["user_id"] == "carol")
assert row["email"] == "carol@team.com"
class TestCleanupTrigger:
def test_deleting_agent_scrubs_its_grants(self, pg_conn):
team = _new_team(pg_conn)
agents = AgentsRepository(pg_conn)
grants = TeamResourceGrantsRepository(pg_conn)
agent = agents.create("alice", "My Agent", "published")
agent_id = str(agent["id"])
grants.grant(team["id"], "agent", agent_id, owner_id="alice", granted_by="alice")
assert grants.get(team["id"], "agent", agent_id) is not None
# AFTER DELETE trigger (migration 0021) removes the dangling grant.
agents.delete(agent_id, "alice")
assert grants.get(team["id"], "agent", agent_id) is None
+12 -2
View File
@@ -625,7 +625,10 @@ def db_mocks():
"""Patch the DB seams of the oidc routes; default: unknown user, active on upsert."""
users_repo = Mock()
users_repo.get.return_value = None
users_repo.upsert.side_effect = lambda user_id: {"user_id": user_id, "active": True}
users_repo.upsert.side_effect = lambda user_id, email=None: {
"user_id": user_id,
"active": True,
}
events_repo = Mock()
@contextmanager
@@ -1115,7 +1118,10 @@ class TestCallbackUserGate:
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
self.db.users.upsert.assert_called_once_with("oidc-user-1")
# New user is provisioned with the email claim (for add-by-email).
self.db.users.upsert.assert_called_once_with(
"oidc-user-1", email="user@example.com"
)
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "oidc_login")
assert call.kwargs["metadata"] == {"email": "user@example.com", "groups": None}
@@ -1127,6 +1133,10 @@ class TestCallbackUserGate:
assert "#oidc_code=" in response.headers["Location"]
self.db.users.upsert.assert_not_called()
# Existing user still gets a targeted email backfill (not a full upsert).
self.db.users.set_email.assert_called_once_with(
"oidc-user-1", "user@example.com"
)
def test_db_outage_does_not_block_login(self, client, fake_redis):
with patch(