mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
65 files changed
+7082
-201
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Teams API namespace: team CRUD, membership, and resource-sharing grants."""
|
||||
|
||||
from .routes import teams_ns
|
||||
|
||||
__all__ = ["teams_ns"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -4,4 +4,4 @@ models:
|
||||
- id: docsgpt-local
|
||||
display_name: DocsGPT Model
|
||||
description: Local model
|
||||
supports_tools: false
|
||||
supports_tools: true
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user