mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
@@ -369,6 +369,11 @@ def resolve_tool_by_id(
|
||||
|
||||
Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved
|
||||
row so callers can branch on either path without losing the discriminator.
|
||||
|
||||
A ``user_tools`` row resolves when ``user`` owns it or a team grant gives
|
||||
``user`` the ``use_in_own`` action on it (checked live, so a revoked grant
|
||||
drops the tool on the next run). The returned row is the owner's, so its
|
||||
``user_id`` is the credential owner.
|
||||
"""
|
||||
default_name = default_tool_name_for_id(tool_id)
|
||||
builtin_name = builtin_agent_tool_name_for_id(tool_id)
|
||||
@@ -382,4 +387,24 @@ def resolve_tool_by_id(
|
||||
return synthesize_builtin_agent_tool(builtin_name)
|
||||
if user_tools_repo is None or not user:
|
||||
return None
|
||||
return user_tools_repo.get_any(str(tool_id), user)
|
||||
row = user_tools_repo.get_any(str(tool_id), user)
|
||||
if row is not None:
|
||||
return row
|
||||
return _resolve_shared_tool(str(tool_id), user, user_tools_repo)
|
||||
|
||||
|
||||
def _resolve_shared_tool(tool_id: str, user: str, user_tools_repo: Any) -> Optional[Dict[str, Any]]:
|
||||
"""The owner's row for a team-shared tool ``user`` may use in their own agents."""
|
||||
conn = getattr(user_tools_repo, "_conn", None)
|
||||
if conn is None:
|
||||
return None
|
||||
# Lazy: resource_access lives under docsgpt.api, whose package import
|
||||
# pulls in every route module (and those import this module).
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
ra = resolve(conn, "tool", tool_id, user)
|
||||
if ra is None or ra.access == "owner" or not ra.can("use_in_own"):
|
||||
if ra is not None:
|
||||
logger.info("shared tool %s not usable by %s (access=%s); dropped", tool_id, user, ra.access)
|
||||
return None
|
||||
return user_tools_repo.get_any(ra.resource_id, ra.owner_id)
|
||||
@@ -14,7 +14,10 @@ from docsgpt.api.answer.services.prompt_renderer import (
|
||||
prompt_embeds_documents,
|
||||
resolve_prompt_skeleton,
|
||||
)
|
||||
from docsgpt.api.answer.services.stream_processor import get_prompt
|
||||
from docsgpt.api.answer.services.stream_processor import (
|
||||
authorized_prompt_id,
|
||||
get_prompt,
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.quotas.service import QuotaExceededError, QuotaService
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -156,7 +159,9 @@ def _run_agent_headless(
|
||||
# ``chunks=0`` switches retrieval off; only a missing value takes the default.
|
||||
raw_chunks = agent_config.get("chunks")
|
||||
chunks = 6 if raw_chunks in (None, "") else int(raw_chunks)
|
||||
prompt_id = agent_config.get("prompt_id", "default")
|
||||
# Runs as the owner: a prompt they can no longer use (revoked grant,
|
||||
# deleted) falls back to the default instead of rendering anyway.
|
||||
prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner)
|
||||
user_api_key = agent_config.get("key")
|
||||
agent_id = _resolve_agent_id(agent_config)
|
||||
agent_type = agent_config.get("agent_type", "classic")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -29,6 +30,45 @@ from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: ``user_tools.config`` key holding an api_tool's encrypted header /
|
||||
#: query-param values: ``{action: {section: {param: value}}}``, keyed by the
|
||||
#: tool owner's id. The plaintext ``value`` of those entries is kept empty.
|
||||
API_TOOL_SECRETS_KEY = "encrypted_action_secrets"
|
||||
API_TOOL_SECRET_SECTIONS = ("headers", "query_params")
|
||||
|
||||
|
||||
def api_tool_action_with_secrets(tool_data: Dict, action_name: str, fallback_owner: Optional[str] = None) -> Dict:
|
||||
"""An api_tool action definition with its stored secret values merged back.
|
||||
|
||||
Secrets are decrypted with the tool row's ``user_id`` (the owner), never
|
||||
the invoker's id. Legacy rows that still hold plaintext values need no
|
||||
merge and are returned as stored.
|
||||
|
||||
Args:
|
||||
tool_data: The ``user_tools`` row.
|
||||
action_name: The action key in ``config["actions"]``.
|
||||
fallback_owner: Used only when the row carries no ``user_id``.
|
||||
|
||||
Returns:
|
||||
A deep copy of the action with header / query-param values filled in.
|
||||
"""
|
||||
config = tool_data.get("config") or {}
|
||||
action = copy.deepcopy(config["actions"][action_name])
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
owner = tool_data.get("user_id") or fallback_owner
|
||||
if not blob or not owner:
|
||||
return action
|
||||
secrets = decrypt_credentials(blob, owner).get(action_name) or {}
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, value in (secrets.get(section) or {}).items():
|
||||
if isinstance(props.get(param), dict):
|
||||
props[param]["value"] = value
|
||||
return action
|
||||
|
||||
|
||||
def record_tool_span_start(call: Any, **attributes: Any) -> Any:
|
||||
"""Open an ``execute_tool`` span for ``call`` (no-op without an active trace)."""
|
||||
@@ -565,6 +605,7 @@ class ToolExecutor:
|
||||
"""Resolve an agentless chat's toolset: explicit user tools plus defaults."""
|
||||
with db_readonly() as conn:
|
||||
user_tools = UserToolsRepository(conn).list_active_for_user(user)
|
||||
user_tools.extend(self._shared_in_chat_tools(conn, user))
|
||||
user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None
|
||||
# Headless agentless runs (e.g. scheduled fire) drop chat-only
|
||||
# tools (``scheduler``) from explicit user_tools too.
|
||||
@@ -581,6 +622,32 @@ class ToolExecutor:
|
||||
tools[str(default_row["id"])] = default_row
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
def _shared_in_chat_tools(conn, user: str) -> List[Dict]:
|
||||
"""Team-shared tools the user switched into their chats and may still use.
|
||||
|
||||
The grant (and the ``use_in_own`` switch) is re-checked on every call;
|
||||
a revoked tool is dropped silently. Rows are the owner's, so their
|
||||
credentials decrypt with the owner's id.
|
||||
"""
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
|
||||
tool_ids = UserToolPreferencesRepository(conn).list_in_chat_tool_ids(user)
|
||||
if not tool_ids:
|
||||
return []
|
||||
repo = UserToolsRepository(conn)
|
||||
rows: List[Dict] = []
|
||||
for tid in tool_ids:
|
||||
row = resolve_tool_by_id(tid, user, user_tools_repo=repo)
|
||||
if row is None or row.get("user_id") == user:
|
||||
if row is None:
|
||||
logger.info("in-chat shared tool %s no longer usable by %s; dropped", tid, user)
|
||||
continue
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict:
|
||||
"""Merge client-provided tool definitions into tools_dict.
|
||||
|
||||
@@ -1261,7 +1328,7 @@ class ToolExecutor:
|
||||
return error_message, call_id
|
||||
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
|
||||
action_data = (
|
||||
tool_data["config"]["actions"][action_name]
|
||||
api_tool_action_with_secrets(tool_data, action_name, self.user)
|
||||
if tool_data["name"] == "api_tool"
|
||||
else next(action for action in tool_data["actions"] if action["name"] == action_name)
|
||||
)
|
||||
@@ -1528,10 +1595,13 @@ class ToolExecutor:
|
||||
if tool_data["name"] == "mcp_tool":
|
||||
tool_config["query_mode"] = True
|
||||
|
||||
# MCP OAuth tokens are looked up by user id: a shared server runs on
|
||||
# the owner's connection, like every other credential.
|
||||
load_user = (tool_data.get("user_id") or self.user) if tool_data["name"] == "mcp_tool" else self.user
|
||||
tool = tm.load_tool(
|
||||
tool_data["name"],
|
||||
tool_config=tool_config,
|
||||
user_id=self.user,
|
||||
user_id=load_user,
|
||||
)
|
||||
|
||||
# Don't cache api_tool since config varies by action
|
||||
|
||||
@@ -19,6 +19,8 @@ WIKI_UPDATED_VIA_AGENT = "agent"
|
||||
|
||||
MAX_WIKI_PAGE_BYTES = 1_000_000
|
||||
|
||||
_WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"})
|
||||
|
||||
|
||||
class WikiTool(Tool):
|
||||
"""Wiki
|
||||
@@ -49,6 +51,11 @@ class WikiTool(Tool):
|
||||
if not self.source_id:
|
||||
return "Error: WikiTool requires a source_id."
|
||||
|
||||
if action_name in _WRITE_ACTIONS:
|
||||
denied = self._write_denied()
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
if action_name == "view":
|
||||
return self._view(kwargs.get("path", "/"), kwargs.get("view_range"))
|
||||
if action_name == "create":
|
||||
@@ -192,6 +199,37 @@ class WikiTool(Tool):
|
||||
},
|
||||
]
|
||||
|
||||
def _write_denied(self) -> Optional[str]:
|
||||
"""Re-check the caller's live ``edit`` right before a write.
|
||||
|
||||
The tool is attached for a writable source when the conversation
|
||||
starts, but a grant can be revoked (or downgraded to viewer) mid-run.
|
||||
Resolving access on every write stops the agent from editing once the
|
||||
caller no longer may. Fails closed on an unknown caller or a failed
|
||||
lookup. Re-embeds still run as the owner.
|
||||
|
||||
Returns:
|
||||
Optional[str]: An error message for the LLM, or None when allowed.
|
||||
"""
|
||||
from docsgpt.api.user import resource_access
|
||||
|
||||
message = "Error: You no longer have edit access to this wiki, so it can't be changed."
|
||||
if not self.updated_by:
|
||||
return message
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
access = resource_access.resolve(
|
||||
conn, "source", str(self.source_id), self.updated_by
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Wiki write access check failed for source %s", self.source_id
|
||||
)
|
||||
return message
|
||||
if access is None or not access.can("edit"):
|
||||
return message
|
||||
return None
|
||||
|
||||
def get_config_requirements(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""0038 resource access settings — per-asset sharing switches and tool chat prefs.
|
||||
|
||||
``resource_share_settings`` holds the owner's per-asset switches that adjust
|
||||
what the fixed roles may do on one shared resource ("Editors can share",
|
||||
"Viewers can see logs", ...). One row per ``(resource_type, resource_id)``;
|
||||
a missing row means every switch is at its default. The table is polymorphic
|
||||
like ``team_resource_grants``, so it has no FK and the switch keys are
|
||||
validated in code (``docsgpt/api/user/resource_access.py``), which keeps new
|
||||
switches migration-free.
|
||||
|
||||
``user_tool_preferences`` is the personal "In my chats" switch for a tool
|
||||
shared with the caller. A grantee can't write the owner's ``user_tools.status``
|
||||
(that is the owner's own chat setting), so each grantee gets a row here.
|
||||
A missing row means off: sharing a tool never adds it to anyone's chats.
|
||||
|
||||
Idempotent both ways.
|
||||
|
||||
Revision ID: 0038_resource_access_settings
|
||||
Revises: 0037_request_traces
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0038_resource_access_settings"
|
||||
down_revision: Union[str, None] = "0037_request_traces"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS resource_share_settings (
|
||||
resource_type TEXT NOT NULL
|
||||
CONSTRAINT resource_share_settings_type_check
|
||||
CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')),
|
||||
resource_id UUID NOT NULL,
|
||||
settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (resource_type, resource_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_tool_preferences (
|
||||
user_id TEXT NOT NULL,
|
||||
tool_id UUID NOT NULL REFERENCES user_tools(id) ON DELETE CASCADE,
|
||||
in_chat BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, tool_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS user_tool_preferences_tool_idx
|
||||
ON user_tool_preferences (tool_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS user_tool_preferences;")
|
||||
op.execute("DROP TABLE IF EXISTS resource_share_settings;")
|
||||
@@ -109,6 +109,50 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str:
|
||||
raise ValueError(f"Invalid prompt ID: {prompt_id}") from e
|
||||
|
||||
|
||||
_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",)
|
||||
|
||||
|
||||
def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any:
|
||||
"""``prompt_id`` if ``principal`` may use it, else ``"default"``.
|
||||
|
||||
Presets pass through. A custom prompt must be owned by ``principal`` or
|
||||
reach them through a team grant with ``use`` (checked live); a revoked,
|
||||
deleted or foreign prompt falls back to the default prompt.
|
||||
|
||||
Args:
|
||||
prompt_id: The configured prompt (preset name, UUID or legacy id).
|
||||
principal: The agent owner for an agent run, else the caller.
|
||||
|
||||
Returns:
|
||||
The prompt id to render.
|
||||
"""
|
||||
if prompt_id is None or prompt_id == "":
|
||||
return prompt_id
|
||||
pid = str(prompt_id)
|
||||
if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW:
|
||||
return prompt_id
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
ra = resolve(conn, "prompt", pid, principal) if principal else None
|
||||
except Exception:
|
||||
logger.exception("Prompt access check failed for %s", pid)
|
||||
ra = None
|
||||
if ra is not None and ra.can("use"):
|
||||
return prompt_id
|
||||
logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
|
||||
return "default"
|
||||
|
||||
|
||||
def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]:
|
||||
"""The owner id to write a wiki source as, when ``caller`` may edit it."""
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
ra = resolve(conn, "source", source_id, caller)
|
||||
return ra.owner_id if ra is not None and ra.can("edit") else None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -934,7 +978,12 @@ class StreamProcessor:
|
||||
|
||||
self.agent_config.update(
|
||||
{
|
||||
"prompt_id": self._agent_data.get("prompt_id", "default"),
|
||||
# The agent runs in its owner's context: its prompt must
|
||||
# be one the owner may use (re-checked on every run).
|
||||
"prompt_id": authorized_prompt_id(
|
||||
self._agent_data.get("prompt_id", "default"),
|
||||
self._agent_data.get("user"),
|
||||
),
|
||||
"agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME),
|
||||
"user_api_key": effective_key,
|
||||
"json_schema": self._agent_data.get("json_schema"),
|
||||
@@ -998,9 +1047,10 @@ class StreamProcessor:
|
||||
if preview_workflow_id:
|
||||
self.agent_config["workflow_id"] = str(preview_workflow_id)
|
||||
|
||||
caller = self.decoded_token.get("sub") if isinstance(self.decoded_token, dict) else None
|
||||
self.agent_config.update(
|
||||
{
|
||||
"prompt_id": self.data.get("prompt_id", "default"),
|
||||
"prompt_id": authorized_prompt_id(self.data.get("prompt_id", "default"), caller),
|
||||
"agent_type": agent_type,
|
||||
"user_api_key": None,
|
||||
"json_schema": None,
|
||||
@@ -1134,14 +1184,12 @@ class StreamProcessor:
|
||||
"""Resolve the WikiTool config for the first writable wiki source.
|
||||
|
||||
A source qualifies when ``SourceConfig.parse(config).kind == "wiki"`` and
|
||||
the principal can write it (``effective_write_owner`` returns an owner —
|
||||
owner or team editor; viewers get None and no tool). v1 supports one
|
||||
the principal may ``edit`` it (owner or team editor; viewers get no
|
||||
tool) — resolved live through ``resource_access``. v1 supports one
|
||||
writable wiki source; the first match wins and the scan stops there so
|
||||
this runs at most one owner+source lookup per chat on the hot path.
|
||||
Returns None when no writable wiki source is present.
|
||||
"""
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
|
||||
caller = self.decoded_token.get("sub") if self.decoded_token else None
|
||||
if not caller:
|
||||
return None
|
||||
@@ -1155,7 +1203,7 @@ class StreamProcessor:
|
||||
if not sid or sid == "default":
|
||||
continue
|
||||
sid = str(sid)
|
||||
owner = effective_write_owner(conn, "source", sid, caller)
|
||||
owner = _wiki_write_owner(conn, sid, caller)
|
||||
if not owner:
|
||||
continue
|
||||
source_doc = repo.get_any(sid, owner)
|
||||
|
||||
@@ -17,6 +17,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import load_source
|
||||
from docsgpt.api.user.tasks import (
|
||||
ingest_connector_task,
|
||||
)
|
||||
@@ -26,7 +28,6 @@ from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
owns_connector_session,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
@@ -499,6 +500,40 @@ class ConnectorDisconnect(Resource):
|
||||
return make_response(jsonify({"success": False, "error": "Failed to disconnect session"}), 500)
|
||||
|
||||
|
||||
def _owner_connector_session(conn, owner_id: str, provider: str) -> Optional[dict]:
|
||||
"""The owner's usable connector session for ``provider``, or None.
|
||||
|
||||
Used when a team editor syncs a shared connector source: the sync runs
|
||||
with the owner's account. A session with no token, no stored credentials,
|
||||
or an expired access token that can't be refreshed counts as missing.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
owner_id: The source owner's ``sub``.
|
||||
provider: The source's connector provider.
|
||||
|
||||
Returns:
|
||||
Optional[dict]: The session row, or None when the owner must reconnect.
|
||||
"""
|
||||
candidates = [
|
||||
s for s in ConnectorSessionsRepository(conn).list_for_user(owner_id)
|
||||
if owns_connector_session(s, owner_id, provider)
|
||||
and s.get("session_token") and s.get("token_info")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
session = candidates[0]
|
||||
token_info = session["token_info"]
|
||||
if not token_info.get("refresh_token"):
|
||||
try:
|
||||
if ConnectorCreator.create_auth(provider).is_token_expired(token_info):
|
||||
return None
|
||||
except Exception:
|
||||
# Providers without an expiry check leave the verdict to the sync.
|
||||
pass
|
||||
return session
|
||||
|
||||
|
||||
@connectors_ns.route("/api/connectors/sync")
|
||||
class ConnectorSync(Resource):
|
||||
@api.expect(
|
||||
@@ -506,7 +541,11 @@ class ConnectorSync(Resource):
|
||||
"ConnectorSyncModel",
|
||||
{
|
||||
"source_id": fields.String(required=True, description="Source ID to sync"),
|
||||
"session_token": fields.String(required=True, description="Authentication token")
|
||||
"session_token": fields.String(
|
||||
required=False,
|
||||
description="The owner's connector session token (ignored for team editors, "
|
||||
"whose sync uses the owner's session)",
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -517,33 +556,41 @@ class ConnectorSync(Resource):
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
data = request.get_json() or {}
|
||||
source_id = data.get('source_id')
|
||||
session_token = data.get('session_token')
|
||||
|
||||
if not all([source_id, session_token]):
|
||||
if not source_id:
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "source_id and session_token are required"
|
||||
}),
|
||||
}),
|
||||
400
|
||||
)
|
||||
user_id = decoded_token.get('sub')
|
||||
with db_readonly() as conn:
|
||||
source = SourcesRepository(conn).get_any(source_id, user_id)
|
||||
if not source:
|
||||
# Owner or team editor. The sync always runs AS the owner, with the
|
||||
# owner's connector account: a grantee can't point the source at
|
||||
# their own account (that is ``reconnect``, owner-only).
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
source, ra = load_source(conn, source_id, user_id, "edit")
|
||||
except AccessDenied as err:
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": err.message, "message": err.message}),
|
||||
err.status,
|
||||
)
|
||||
owner_id = ra.owner_id
|
||||
is_owner = ra.access == "owner"
|
||||
if is_owner and not session_token:
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "Source not found"
|
||||
"error": "source_id and session_token are required"
|
||||
}),
|
||||
404
|
||||
400
|
||||
)
|
||||
|
||||
# ``get_any`` already scopes by ``user_id``; an extra guard
|
||||
# here would be dead code.
|
||||
|
||||
remote_data = source.get('remote_data') or {}
|
||||
if isinstance(remote_data, str):
|
||||
try:
|
||||
@@ -558,17 +605,31 @@ class ConnectorSync(Resource):
|
||||
jsonify({
|
||||
"success": False,
|
||||
"error": "Source provider not found in remote_data"
|
||||
}),
|
||||
}),
|
||||
400
|
||||
)
|
||||
|
||||
with db_readonly() as conn:
|
||||
session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
|
||||
if not owns_connector_session(session, user_id, source_type):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "Invalid or unauthorized session"}),
|
||||
401,
|
||||
)
|
||||
if is_owner:
|
||||
with db_readonly() as conn:
|
||||
session = ConnectorSessionsRepository(conn).get_by_session_token(session_token)
|
||||
if not owns_connector_session(session, user_id, source_type):
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": "Invalid or unauthorized session"}),
|
||||
401,
|
||||
)
|
||||
else:
|
||||
with db_readonly() as conn:
|
||||
session = _owner_connector_session(conn, owner_id, source_type)
|
||||
if session is None:
|
||||
message = (
|
||||
"The owner needs to reconnect this source's account "
|
||||
"before it can be synced."
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": message, "message": message}),
|
||||
409,
|
||||
)
|
||||
session_token = session["session_token"]
|
||||
|
||||
# Extract configuration from remote_data
|
||||
file_ids = remote_data.get('file_ids', [])
|
||||
@@ -578,7 +639,7 @@ class ConnectorSync(Resource):
|
||||
# Start the sync task
|
||||
task = ingest_connector_task.delay(
|
||||
job_name=source.get('name'),
|
||||
user=decoded_token.get('sub'),
|
||||
user=owner_id,
|
||||
source_type=source_type,
|
||||
session_token=session_token,
|
||||
file_ids=file_ids,
|
||||
|
||||
@@ -309,6 +309,7 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/teams/<string:team_id>/members", "GET"): _rule("teams:read"),
|
||||
("/api/teams/<string:team_id>/grants", "GET"): _rule("teams:read"),
|
||||
("/api/resource_shares", "GET"): _rule("teams:read"),
|
||||
("/api/resource_settings", "GET"): _rule("teams:read"),
|
||||
# Chat
|
||||
("/api/answer", "POST"): _rule("chat:run", **_CHAT),
|
||||
("/stream", "POST"): _rule("chat:run", **_CHAT),
|
||||
@@ -355,6 +356,7 @@ DENIED: dict[str, tuple[str, ...]] = {
|
||||
"/api/teams/<string:team_id>/members/<string:member_id>": ("*",),
|
||||
"/api/teams/<string:team_id>/grants": ("POST", "DELETE"),
|
||||
"/api/teams/<string:team_id>/transfer_owner": ("*",),
|
||||
"/api/resource_settings": ("PUT",),
|
||||
"/swagger.json": ("*",),
|
||||
}
|
||||
DENIED_PREFIXES = (
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask_restx import Namespace, Resource, fields
|
||||
from sqlalchemy import text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, payload_for, require, settings_many
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -143,11 +144,16 @@ class AgentFolder(Resource):
|
||||
),
|
||||
{"user_id": user, "fid": pg_folder_id},
|
||||
).fetchall()
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(row._mapping["id"]) for row in agents_rows]
|
||||
)
|
||||
# Folder contents are the caller's own agents.
|
||||
agents_list = [
|
||||
{
|
||||
"id": str(row._mapping["id"]),
|
||||
"name": row._mapping["name"],
|
||||
"description": row._mapping.get("description", "") or "",
|
||||
**payload_for("agent", "owner", switches[str(row._mapping["id"])]),
|
||||
}
|
||||
for row in agents_rows
|
||||
]
|
||||
@@ -298,7 +304,14 @@ class MoveAgentToFolder(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
# Folders are the owner's own organisation of their agents.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id_input, user, "move_folder")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}),
|
||||
@@ -357,10 +370,18 @@ class BulkMoveAgents(Resource):
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
# Only the caller's own agents move (``move_folder`` is
|
||||
# owner-only); anything else is reported back, not dropped.
|
||||
moved, skipped = [], []
|
||||
for agent_id_input in agent_ids:
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
if agent is not None:
|
||||
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
moved.append(str(agent_id_input))
|
||||
else:
|
||||
skipped.append(str(agent_id_input))
|
||||
return make_response(
|
||||
jsonify({"success": True, "moved": moved, "skipped": skipped}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agents", err)
|
||||
@@ -4,7 +4,7 @@ from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.team_sharing import team_access_for
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.guardrails.checks.patterns import DEFAULT_PII_ENTITIES, PII_PATTERNS
|
||||
from docsgpt.guardrails.config import DEFAULT_BLOCK_MESSAGE, MODES
|
||||
@@ -70,15 +70,30 @@ class GuardrailCatalog(Resource):
|
||||
)
|
||||
|
||||
|
||||
def _readable_agent(conn, agent_id: str, user: str):
|
||||
"""Return the agent row when the caller may read it, else None."""
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if agent:
|
||||
return agent
|
||||
if team_access_for(conn, user, "agent", agent_id):
|
||||
return repo.get_by_id(agent_id)
|
||||
return None
|
||||
def _logs_access(conn, agent_id: str, user: str) -> tuple[dict, ResourceAccess]:
|
||||
"""The agent row and the caller's access, for reading its guardrail journal.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id (UUID or legacy).
|
||||
user: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, access)``; rows are read as ``access.owner_id``, so a team
|
||||
member with ``view_logs`` sees exactly what the owner sees.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without ``view_logs``.
|
||||
"""
|
||||
ra = require(conn, "agent", agent_id, user, "view_logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "Agent not found")
|
||||
return agent, ra
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
@agents_guardrails_ns.route("/guardrails/events")
|
||||
@@ -106,17 +121,16 @@ class GuardrailEvents(Resource):
|
||||
400,
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Query on the row's UUID, not the caller's argument: a legacy
|
||||
# 24-hex Mongo id resolves fine above but would blow up the cast.
|
||||
# Rows stay scoped to the requesting user even on a shared agent —
|
||||
# another member's blocked prompts are not this caller's to read.
|
||||
# Rows are the owner's view: ``view_logs`` shows a team member
|
||||
# what the owner sees, never other members' own chats.
|
||||
events = GuardrailEventsRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user, limit=limit, offset=offset
|
||||
str(agent["id"]), ra.owner_id, limit=limit, offset=offset
|
||||
)
|
||||
return make_response(jsonify({"success": True, "events": events}), 200)
|
||||
|
||||
@@ -143,14 +157,15 @@ class GuardrailSummary(Resource):
|
||||
agent_id = request.args.get("agent_id")
|
||||
with db_readonly() as conn:
|
||||
scoped_id = None
|
||||
scope_user = user
|
||||
if agent_id:
|
||||
agent = _readable_agent(conn, agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
try:
|
||||
agent, ra = _logs_access(conn, agent_id, user)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
scoped_id = str(agent["id"])
|
||||
scope_user = ra.owner_id
|
||||
summary = GuardrailEventsRepository(conn).summary_for_user(
|
||||
user, days=days, agent_id=scoped_id
|
||||
scope_user, days=days, agent_id=scoped_id
|
||||
)
|
||||
return make_response(jsonify({"success": True, **summary}), 200)
|
||||
@@ -37,6 +37,7 @@ from docsgpt.agents.default_tools import (
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import allowed_ids
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.model_utils import validate_model_id
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url
|
||||
@@ -1756,14 +1757,23 @@ class ExportAgent(Resource):
|
||||
return make_response(jsonify({"success": False, "message": "id is required"}), 400)
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "export")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, user)
|
||||
# Serialized as the owner: the agent's prompt, sources, tools and
|
||||
# workflow are the owner's (secrets are never exported).
|
||||
owner_id = ra.owner_id
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, owner_id)
|
||||
try:
|
||||
export = serialize_agent(conn, agent, user)
|
||||
export = serialize_agent(conn, agent, owner_id)
|
||||
except AgentExportError as exc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": str(exc)}), 400
|
||||
|
||||
@@ -26,9 +26,16 @@ from docsgpt.core.json_schema_utils import (
|
||||
)
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
resolve,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
team_access_for,
|
||||
visible_with_access,
|
||||
)
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
@@ -196,6 +203,83 @@ def _resolve_folder_id(conn, folder_id, user):
|
||||
return str(folder["id"]), None
|
||||
|
||||
|
||||
# What a caller who reached an agent only through its public share link may
|
||||
# do: chat with it and pin it. A team grant, when there is one, wins.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
# Agent fields that are the owner's policy (guardrails and the pooled quota).
|
||||
POLICY_FIELDS = (
|
||||
"config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode",
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
|
||||
|
||||
Builtin synthetic ids belong to no one. Otherwise the tool must be the
|
||||
agent owner's (it runs with the owner's credentials) or reach the caller
|
||||
with ``use_in_own``.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
tool_id: The tool id being attached.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the attachment is allowed.
|
||||
"""
|
||||
tid = str(tool_id)
|
||||
if is_synthesized_tool_id(tid):
|
||||
return True
|
||||
if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
|
||||
return True
|
||||
ra = resolve(conn, "tool", tid, caller)
|
||||
return ra is not None and ra.can("use_in_own")
|
||||
|
||||
|
||||
def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
resource_type: ``source`` or ``prompt``.
|
||||
resource_id: The referenced id.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the agent owner owns it or the caller can ``use`` it.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
|
||||
return True
|
||||
return can_access(conn, resource_type, str(resource_id), caller)
|
||||
|
||||
|
||||
def _policy_changed(existing: dict, update_fields: dict) -> bool:
|
||||
"""True when ``update_fields`` changes any guardrail or quota value."""
|
||||
for key in POLICY_FIELDS:
|
||||
if key not in update_fields:
|
||||
continue
|
||||
new, old = update_fields[key], existing.get(key)
|
||||
if key == "config":
|
||||
if (new or {}) != (old or {}):
|
||||
return True
|
||||
elif key.startswith("limited_"):
|
||||
if bool(new) != bool(old):
|
||||
return True
|
||||
elif int(new or 0) != int(old or 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _reject(message: str, user: str, field: str = "-"):
|
||||
"""Log a request-validation rejection at WARN and return its 400 response.
|
||||
|
||||
@@ -228,6 +312,7 @@ def _format_agent_output(
|
||||
ownership: str = "user",
|
||||
team_access: str | None = None,
|
||||
resolve_names: bool = False,
|
||||
access: dict | None = None,
|
||||
) -> dict:
|
||||
"""Shape a PG agent row into the outward API response dict.
|
||||
|
||||
@@ -239,7 +324,16 @@ def _format_agent_output(
|
||||
``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for
|
||||
ones shared with a team they're in; ``team_access`` (``viewer``/``editor``)
|
||||
is set on team-shared agents so the UI can gate edit controls.
|
||||
|
||||
``access`` is the caller's ``{"access", "allowed_actions"}`` payload
|
||||
(owner with default switches when omitted). It is embedded verbatim and
|
||||
decides what leaves the server: the full guardrail ``config`` needs
|
||||
``view``, the (masked) ``key`` and public ``shared_token`` need
|
||||
``manage_access_details``.
|
||||
"""
|
||||
if access is None:
|
||||
access = payload_for("agent", "owner", {})
|
||||
allowed = set(access.get("allowed_actions") or [])
|
||||
source_id = agent.get("source_id")
|
||||
extra_source_ids = agent.get("extra_source_ids") or []
|
||||
source_value = str(source_id) if source_id else ""
|
||||
@@ -288,7 +382,13 @@ def _format_agent_output(
|
||||
),
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
"access": access.get("access"),
|
||||
"allowed_actions": sorted(allowed),
|
||||
}
|
||||
# Guardrail policy is edit-page config; a chat-only caller doesn't need it
|
||||
# (and reading the banned-term list makes evading it trivial).
|
||||
if "view" not in allowed:
|
||||
out["config"] = {}
|
||||
# Resolve prompt/source NAMES by id (owner-agnostic) so a team member
|
||||
# viewing a shared agent sees the owner's prompt + source names instead of
|
||||
# a blank prompt / "External KB" (the client otherwise resolves these from
|
||||
@@ -298,9 +398,9 @@ def _format_agent_output(
|
||||
out["source_details"] = resolve_source_details(
|
||||
([source_id] if source_id else []) + list(extra_source_ids)
|
||||
)
|
||||
# Never expose the owner's share/API secrets to a team grantee — the
|
||||
# public ``shared_token`` and the (masked) agent ``key`` are owner-only.
|
||||
if ownership == "team":
|
||||
# The public ``shared_token`` and the (masked) agent ``key`` are access
|
||||
# details: only a caller who may manage them sees them.
|
||||
if "manage_access_details" not in allowed:
|
||||
out["shared_token"] = ""
|
||||
return out
|
||||
if include_key_masked:
|
||||
@@ -435,23 +535,22 @@ class GetAgent(Resource):
|
||||
return {"success": False, "message": "ID required"}, 400
|
||||
try:
|
||||
user = decoded_token["sub"]
|
||||
ownership, team_access = "user", None
|
||||
agent = None
|
||||
with db_readonly() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if not agent:
|
||||
# Team fallback: only after a grant check, fetch ownerless.
|
||||
team_access = team_access_for(conn, user, "agent", agent_id)
|
||||
if team_access:
|
||||
agent = repo.get_by_id(agent_id)
|
||||
ownership = "team"
|
||||
# Anyone who can see the agent reads it (a viewer needs it to
|
||||
# chat); what they get back is trimmed by their actions.
|
||||
ra = resolve(conn, "agent", agent_id, user)
|
||||
if ra is not None:
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return {"status": "Not found"}, 404
|
||||
is_owner = ra.access == "owner"
|
||||
data = _format_agent_output(
|
||||
agent,
|
||||
ownership=ownership,
|
||||
team_access=team_access,
|
||||
ownership="user" if is_owner else "team",
|
||||
team_access=None if is_owner else ra.access,
|
||||
resolve_names=True,
|
||||
access=ra.payload(),
|
||||
)
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as e:
|
||||
@@ -483,10 +582,18 @@ class GetAgents(Resource):
|
||||
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
|
||||
shared_agents = agents_repo.list_by_ids(shared_ids)
|
||||
|
||||
switches = settings_many(
|
||||
conn, "agent", [str(a["id"]) for a in agents + shared_agents]
|
||||
)
|
||||
|
||||
# Every agent is listed: one with no source skips retrieval and
|
||||
# answers from the model and its tools, so it is still runnable.
|
||||
list_agents = [
|
||||
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
|
||||
_format_agent_output(
|
||||
agent,
|
||||
pinned=str(agent["id"]) in pinned_ids,
|
||||
access=payload_for("agent", "owner", switches[str(agent["id"])]),
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
list_agents += [
|
||||
@@ -494,6 +601,11 @@ class GetAgents(Resource):
|
||||
agent,
|
||||
ownership="team",
|
||||
team_access=team_shared.get(str(agent["id"])),
|
||||
access=payload_for(
|
||||
"agent",
|
||||
team_shared.get(str(agent["id"])),
|
||||
switches[str(agent["id"])],
|
||||
),
|
||||
)
|
||||
for agent in shared_agents
|
||||
]
|
||||
@@ -731,6 +843,14 @@ class CreateAgent(Resource):
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}),
|
||||
403,
|
||||
)
|
||||
# Tools run with the agent owner's credentials: attach only your
|
||||
# own, or ones a team lets you use in your agents.
|
||||
for tid in data.get("tools") or []:
|
||||
if not _tool_attachable(conn, tid, user, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not accessible"}),
|
||||
403,
|
||||
)
|
||||
|
||||
build_data = dict(data)
|
||||
build_data["folder_id"] = pg_folder_id
|
||||
@@ -888,23 +1008,15 @@ class UpdateAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
is_team_editor = False
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
if not existing_agent:
|
||||
# Team write path: only an 'editor' grant may modify a
|
||||
# team-shared agent; a 'viewer' is read-only. Fetch the
|
||||
# ownerless row only AFTER confirming editor access.
|
||||
access = team_access_for(conn, user, "agent", agent_id)
|
||||
if access == "editor":
|
||||
existing_agent = agents_repo.get_by_id(agent_id)
|
||||
is_team_editor = True
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Every write lands as the owner; the row is fetched only after
|
||||
# the access check above.
|
||||
owner_id = ra.owner_id
|
||||
is_team_editor = ra.access != "owner"
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -959,6 +1071,8 @@ class UpdateAgent(Resource):
|
||||
user,
|
||||
field,
|
||||
)
|
||||
if new_status != existing_agent.get("status") and not ra.can("publish"):
|
||||
return _denied(AccessDenied(403, "Your access to this item doesn't allow that"))
|
||||
update_fields["status"] = new_status
|
||||
elif field == "source":
|
||||
source_id = data.get("source")
|
||||
@@ -1084,10 +1198,24 @@ class UpdateAgent(Resource):
|
||||
field,
|
||||
)
|
||||
elif field == "folder_id":
|
||||
folder_input = data.get("folder_id")
|
||||
# Folders are the owner's own organisation; re-sending
|
||||
# the current folder is a no-op anyone may do.
|
||||
folder_input = data.get("folder_id") or None
|
||||
current_folder = (
|
||||
str(existing_agent["folder_id"])
|
||||
if existing_agent.get("folder_id")
|
||||
else None
|
||||
)
|
||||
if folder_input == current_folder:
|
||||
update_fields["folder_id"] = current_folder
|
||||
continue
|
||||
if not ra.can("move_folder"):
|
||||
return _denied(
|
||||
AccessDenied(403, "Only the owner can move this agent between folders")
|
||||
)
|
||||
if folder_input:
|
||||
pg_folder_id, folder_err = _resolve_folder_id(
|
||||
conn, folder_input, user,
|
||||
conn, folder_input, owner_id,
|
||||
)
|
||||
if folder_err:
|
||||
return folder_err
|
||||
@@ -1107,8 +1235,10 @@ class UpdateAgent(Resource):
|
||||
return _reject("Workflow is required", user, field)
|
||||
update_fields["workflow_id"] = None
|
||||
else:
|
||||
# The agent runs its workflow as the owner, so it
|
||||
# must be one of the owner's workflows.
|
||||
pg_workflow_id, wf_err = _resolve_workflow_for_user(
|
||||
conn, workflow_input, user,
|
||||
conn, workflow_input, owner_id,
|
||||
)
|
||||
if wf_err:
|
||||
return wf_err
|
||||
@@ -1220,7 +1350,7 @@ class UpdateAgent(Resource):
|
||||
for sid in referenced_sources:
|
||||
if str(sid) in existing_source_refs:
|
||||
continue
|
||||
if not can_access(conn, "source", sid, user):
|
||||
if not _ref_attachable(conn, "source", sid, owner_id, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
@@ -1228,28 +1358,24 @@ class UpdateAgent(Resource):
|
||||
if (
|
||||
new_prompt_id
|
||||
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
|
||||
and not can_access(conn, "prompt", new_prompt_id, user)
|
||||
and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}), 403
|
||||
)
|
||||
# A team editor must not attach tools they can't access onto a
|
||||
# shared agent: at run time the agent-key path resolves+decrypts
|
||||
# tools as the OWNER, so an unchecked tool here would let an
|
||||
# editor invoke arbitrary owner credentials. Owners are
|
||||
# unrestricted (they own their tools). Default/builtin synthetic
|
||||
# tool ids belong to no one and are always allowed.
|
||||
if is_team_editor and "tools" in update_fields:
|
||||
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
||||
|
||||
# Tools run with the OWNER's credentials (the agent-key path
|
||||
# resolves and decrypts them as the owner), so a newly attached
|
||||
# tool must be the owner's or reach the caller with
|
||||
# ``use_in_own``. Tools already on the agent stay. Builtin
|
||||
# synthetic ids belong to no one and are always allowed.
|
||||
if "tools" in update_fields:
|
||||
existing_tools = {
|
||||
str(t) for t in (existing_agent.get("tools") or [])
|
||||
}
|
||||
for tid in update_fields["tools"] or []:
|
||||
tid_s = str(tid)
|
||||
if tid_s in existing_tools or is_synthesized_tool_id(tid_s):
|
||||
if str(tid) in existing_tools:
|
||||
continue
|
||||
if not can_access(conn, "tool", tid_s, user):
|
||||
if not _tool_attachable(conn, tid, owner_id, user):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Tool not accessible"}
|
||||
@@ -1257,19 +1383,13 @@ class UpdateAgent(Resource):
|
||||
403,
|
||||
)
|
||||
|
||||
# Per-agent quota lives on the row and is pooled across all
|
||||
# members; only the owner may resize that shared pool, so a
|
||||
# team editor's quota changes are dropped.
|
||||
if is_team_editor:
|
||||
for _q in (
|
||||
"token_limit", "request_limit",
|
||||
"limited_token_mode", "limited_request_mode",
|
||||
# Guardrails are the owner's policy for their agent.
|
||||
# An editor who could clear them would silently strip
|
||||
# protection from everyone else using it.
|
||||
"config",
|
||||
):
|
||||
update_fields.pop(_q, None)
|
||||
# Guardrails and the pooled quota are policy: an unchanged
|
||||
# value re-sent by a full-form save is fine, a change needs
|
||||
# ``edit_policy``.
|
||||
if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields):
|
||||
return _denied(
|
||||
AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
|
||||
)
|
||||
|
||||
# Apply update. Owner writes use the dual-key guard; team-editor
|
||||
# writes go by-id (already authorized) with an optimistic-lock
|
||||
@@ -1328,7 +1448,9 @@ class UpdateAgent(Resource):
|
||||
"id": pg_agent_id,
|
||||
"message": "Agent updated successfully",
|
||||
}
|
||||
if newly_generated_key:
|
||||
# A freshly minted key is an access detail: returned only to a caller
|
||||
# who may manage it (the key is still stored either way).
|
||||
if newly_generated_key and ra.can("manage_access_details"):
|
||||
response_data["key"] = (
|
||||
newly_generated_key
|
||||
if may_see_agent_keys(request)
|
||||
@@ -1358,10 +1480,13 @@ class RegenerateAgentKey(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
# Owner-only: rotating a credential is destructive to live
|
||||
# integrations, so this is intentionally stricter than
|
||||
# update_agent (which also allows team editors).
|
||||
existing_agent = agents_repo.get_any(agent_id, user)
|
||||
# Rotating the key is an access detail: the owner, or an editor
|
||||
# while ``editors_can_manage_access_details`` is on.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not existing_agent:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1389,7 +1514,7 @@ class RegenerateAgentKey(Resource):
|
||||
)
|
||||
|
||||
new_key = str(uuid.uuid4())
|
||||
updated = agents_repo.update(pg_agent_id, user, {"key": new_key})
|
||||
updated = agents_repo.update(pg_agent_id, ra.owner_id, {"key": new_key})
|
||||
if not updated:
|
||||
return make_response(
|
||||
jsonify(
|
||||
@@ -1460,7 +1585,12 @@ class DeleteAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id, user)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
owner_id = ra.owner_id
|
||||
agent = agents_repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -1476,14 +1606,20 @@ class DeleteAgent(Resource):
|
||||
# that user's graph.
|
||||
if agent.get("agent_type") == "workflow" and workflow_id:
|
||||
try:
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), user)
|
||||
WorkflowsRepository(conn).delete(str(workflow_id), owner_id)
|
||||
except Exception as wf_err:
|
||||
current_app.logger.warning(
|
||||
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
|
||||
)
|
||||
agents_repo.delete(pg_agent_id, user)
|
||||
# Strip pinned/shared entries for this agent from the owner's prefs.
|
||||
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
|
||||
# Team grants go with the row (AFTER DELETE trigger, 0021);
|
||||
# the switches table has no FK, so drop it explicitly.
|
||||
agents_repo.delete(pg_agent_id, owner_id)
|
||||
delete_settings(conn, "agent", pg_agent_id)
|
||||
# Strip pinned/shared entries for this agent from the owner's
|
||||
# (and the deleting editor's) prefs.
|
||||
users_repo = UsersRepository(conn)
|
||||
for uid in {owner_id, user}:
|
||||
users_repo.remove_agent_from_all(uid, pg_agent_id)
|
||||
record_event(
|
||||
conn,
|
||||
"agent.deleted",
|
||||
@@ -1579,10 +1715,19 @@ class PinnedAgents(Resource):
|
||||
for agent in pinned_agents
|
||||
if _user_may_pin(conn, agent, user_id, shared_with_me)
|
||||
]
|
||||
# A pin reached only through a share link (or a template)
|
||||
# has no grant: chat + pin, nothing more.
|
||||
access_by_id = {}
|
||||
for agent in pinned_agents:
|
||||
ra = resolve(conn, "agent", str(agent["id"]), user_id)
|
||||
access_by_id[str(agent["id"])] = (
|
||||
ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS)
|
||||
)
|
||||
|
||||
list_pinned_agents = []
|
||||
for agent in pinned_agents:
|
||||
source_id = agent.get("source_id")
|
||||
access = access_by_id[str(agent["id"])]
|
||||
list_pinned_agents.append(
|
||||
{
|
||||
"id": str(agent["id"]),
|
||||
@@ -1608,10 +1753,12 @@ class PinnedAgents(Resource):
|
||||
"last_used_at": agent.get("last_used_at", ""),
|
||||
"key": (
|
||||
f"{agent['key'][:4]}...{agent['key'][-4:]}"
|
||||
if agent.get("key") and agent.get("user_id") == user_id
|
||||
if agent.get("key")
|
||||
and "manage_access_details" in access["allowed_actions"]
|
||||
else ""
|
||||
),
|
||||
"pinned": True,
|
||||
**access,
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import text as _sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.api.user.base import resolve_tool_details
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require, resolve
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -21,12 +22,38 @@ agents_sharing_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
# A caller who reached an agent only through its public link may chat with it
|
||||
# and pin it; a team grant, when there is one, gives more.
|
||||
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
||||
|
||||
|
||||
def _link_access(conn, agent_id: str, user_id) -> dict:
|
||||
"""The ``access`` payload for an agent the caller reached by share link.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
agent_id: The agent's id.
|
||||
user_id: The caller, or None when anonymous.
|
||||
|
||||
Returns:
|
||||
The caller's own access (owner or a team grant) when they have one,
|
||||
else viewer with ``pin`` and ``use``.
|
||||
"""
|
||||
if user_id:
|
||||
ra = resolve(conn, "agent", agent_id, user_id)
|
||||
if ra is not None:
|
||||
return ra.payload()
|
||||
return dict(LINK_SHARED_ACCESS)
|
||||
|
||||
|
||||
def _serialize_agent_basic(agent: dict) -> dict:
|
||||
"""Shape a PG agent row into the API response dict."""
|
||||
"""Shape a PG agent row into the API response dict.
|
||||
|
||||
The owner's user id is deliberately not included: a share link is public.
|
||||
"""
|
||||
source_id = agent.get("source_id")
|
||||
return {
|
||||
"id": str(agent["id"]),
|
||||
"user": agent.get("user_id", ""),
|
||||
"name": agent.get("name", ""),
|
||||
"image": (
|
||||
generate_image_url(
|
||||
@@ -91,8 +118,8 @@ class SharedAgent(Resource):
|
||||
enriched_tools.append(detail.get("name", ""))
|
||||
data["tools"] = enriched_tools
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
if decoded_token:
|
||||
user_id = decoded_token.get("sub")
|
||||
user_id = decoded_token.get("sub") if decoded_token else None
|
||||
if user_id:
|
||||
owner_id = shared_agent.get("user_id")
|
||||
|
||||
if user_id != owner_id:
|
||||
@@ -100,6 +127,8 @@ class SharedAgent(Resource):
|
||||
users_repo = UsersRepository(conn)
|
||||
users_repo.upsert(user_id)
|
||||
users_repo.add_shared(user_id, agent_id)
|
||||
with db_readonly() as conn:
|
||||
data.update(_link_access(conn, agent_id, user_id))
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving shared agent: {err}")
|
||||
@@ -152,6 +181,10 @@ class SharedAgents(Resource):
|
||||
if isinstance(user_doc.get("agent_preferences"), dict)
|
||||
else []
|
||||
)
|
||||
access_by_id = {
|
||||
str(agent["id"]): _link_access(conn, str(agent["id"]), user_id)
|
||||
for agent in shared_agents
|
||||
}
|
||||
|
||||
list_shared_agents = []
|
||||
for agent in shared_agents:
|
||||
@@ -185,6 +218,7 @@ class SharedAgents(Resource):
|
||||
"shared": bool(agent.get("shared", False)),
|
||||
"shared_token": agent.get("shared_token", "") or "",
|
||||
"shared_metadata": agent.get("shared_metadata", {}) or {},
|
||||
**access_by_id[agent_id_str],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -244,7 +278,15 @@ class ShareAgent(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
# The public link is an access detail; it is written as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
owner_id = ra.owner_id
|
||||
agent = repo.get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -258,7 +300,7 @@ class ShareAgent(Resource):
|
||||
}
|
||||
shared_token = secrets.token_urlsafe(32)
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": True,
|
||||
"shared_token": shared_token,
|
||||
@@ -267,7 +309,7 @@ class ShareAgent(Resource):
|
||||
)
|
||||
else:
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), owner_id,
|
||||
{
|
||||
"shared": False,
|
||||
"shared_token": None,
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import require_agent
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.tasks import process_agent_webhook
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
@@ -71,7 +72,14 @@ class AgentWebhook(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
agent = AgentsRepository(conn).get_any(agent_id, user)
|
||||
# The webhook URL is an access detail; it is minted as the owner.
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
||||
except AccessDenied as denied:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": denied.message}), denied.status
|
||||
)
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
@@ -81,7 +89,7 @@ class AgentWebhook(Resource):
|
||||
webhook_token = secrets.token_urlsafe(32)
|
||||
with db_session() as conn:
|
||||
AgentsRepository(conn).update(
|
||||
str(agent["id"]), user,
|
||||
str(agent["id"]), ra.owner_id,
|
||||
{"incoming_webhook_token": webhook_token},
|
||||
)
|
||||
base_url = settings.API_URL.rstrip("/")
|
||||
|
||||
@@ -8,6 +8,7 @@ from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text as _sql_text
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, resolve
|
||||
from docsgpt.api.user.base import (
|
||||
generate_date_range,
|
||||
generate_hourly_range,
|
||||
@@ -74,27 +75,38 @@ def _intervals_for_filter(filter_option, start_date, end_date):
|
||||
|
||||
|
||||
def _resolve_agent(conn, api_key_id, user_id):
|
||||
"""Owner-scoped agent lookup for analytics filters.
|
||||
"""Access-checked agent lookup for analytics filters.
|
||||
|
||||
Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when
|
||||
the id doesn't resolve to one of the caller's agents — callers must
|
||||
the id doesn't resolve to an agent the caller can see — callers must
|
||||
short-circuit with an empty result, not fall back to sentinel filter
|
||||
values. ``api_key`` is ``None`` (never ``""``) for key-less agents:
|
||||
draft agents store ``key = ''``, and an ``''`` filter would match the
|
||||
``''`` that writers like ``stack_logs`` stamp on every key-less
|
||||
request — leaking rows across users. NULL matches nothing. Accepts
|
||||
UUID or legacy Mongo ObjectId ids.
|
||||
values. A visible agent needs ``view_logs`` (owner and editors; viewers
|
||||
when the owner turns on ``viewers_can_see_logs``), and the caller then
|
||||
sees exactly the owner's view of it. ``api_key`` is ``None`` (never
|
||||
``""``) for key-less agents: draft agents store ``key = ''``, and an
|
||||
``''`` filter would match the ``''`` that writers like ``stack_logs``
|
||||
stamp on every key-less request — leaking rows across users. NULL
|
||||
matches nothing. Accepts UUID or legacy Mongo ObjectId ids.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 403 when the agent is visible but ``view_logs`` isn't allowed.
|
||||
"""
|
||||
agent = (
|
||||
AgentsRepository(conn).get_any(api_key_id, user_id)
|
||||
if api_key_id
|
||||
else None
|
||||
)
|
||||
ra = resolve(conn, "agent", api_key_id, user_id) if api_key_id else None
|
||||
if ra is None:
|
||||
return None, None, None
|
||||
if not ra.can("view_logs"):
|
||||
raise AccessDenied(403, "Your access to this agent doesn't include its logs")
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
api_key = (agent or {}).get("key") or None
|
||||
agent_pg_id = str(agent["id"]) if agent else None
|
||||
return agent, api_key, agent_pg_id
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _trace_branch(name: str, sources_sql: str, scope: str) -> dict:
|
||||
"""A ``get_user_logs`` branch listing stored traces of the given sources."""
|
||||
return {
|
||||
@@ -238,6 +250,8 @@ class GetTraces(Resource):
|
||||
traces = RequestTracesRepository(conn).list_by_ref(
|
||||
field, value, user_id=user, agent_id=agent_pg_id
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error getting traces: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -342,6 +356,8 @@ class GetMessageAnalytics(Resource):
|
||||
daily_messages = {interval: 0 for interval in intervals}
|
||||
for row in rows:
|
||||
daily_messages[row._mapping["bucket"]] = int(row._mapping["count"])
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting message analytics: {err}", exc_info=True
|
||||
@@ -466,6 +482,8 @@ class GetTokenAnalytics(Resource):
|
||||
if key not in series:
|
||||
series[key] = {interval: 0 for interval in intervals}
|
||||
series[key][bucket] = series[key].get(bucket, 0) + total
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting token analytics: {err}", exc_info=True
|
||||
@@ -592,6 +610,8 @@ class GetFeedbackAnalytics(Resource):
|
||||
"positive": int(row._mapping["positive"] or 0),
|
||||
"negative": int(row._mapping["negative"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting feedback analytics: {err}", exc_info=True
|
||||
@@ -710,6 +730,8 @@ class GetToolAnalytics(Resource):
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting tool analytics: {err}", exc_info=True
|
||||
@@ -825,6 +847,8 @@ class GetScheduleAnalytics(Resource):
|
||||
"failed": int(row._mapping["failed"] or 0),
|
||||
"skipped": int(row._mapping["skipped"] or 0),
|
||||
}
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting schedule analytics: {err}", exc_info=True
|
||||
@@ -927,7 +951,8 @@ class GetUserLogs(Resource):
|
||||
200,
|
||||
)
|
||||
params: dict = {
|
||||
"user_id": user,
|
||||
# Agent-scoped logs are the owner's view of the agent.
|
||||
"user_id": agent["user_id"] if agent else user,
|
||||
"limit": page_size + 1,
|
||||
"offset": (page - 1) * page_size,
|
||||
}
|
||||
@@ -1313,6 +1338,8 @@ class GetUserLogs(Resource):
|
||||
"Could not attach trace summaries to the logs page",
|
||||
exc_info=True,
|
||||
)
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error getting user logs: {err}", exc_info=True
|
||||
|
||||
@@ -6,7 +6,14 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.team_sharing import team_access_for, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.prompts.composer import compose_preset, is_composed_preset
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -17,6 +24,16 @@ prompts_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _iso(value):
|
||||
"""ISO-8601 string for a timestamp (``expected_updated_at`` round-trips it)."""
|
||||
return value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
|
||||
@prompts_ns.route("/create_prompt")
|
||||
class CreatePrompt(Resource):
|
||||
create_prompt_model = api.model(
|
||||
@@ -67,26 +84,35 @@ class GetPrompts(Resource):
|
||||
team_shared = visible_with_access(conn, user, "prompt")
|
||||
shared_ids = [pid for pid in team_shared if pid not in owned_ids]
|
||||
shared_prompts = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(
|
||||
conn, "prompt", [*owned_ids, *(str(p["id"]) for p in shared_prompts)]
|
||||
)
|
||||
list_prompts = [
|
||||
{"id": "default", "name": "default", "type": "public"},
|
||||
{"id": "creative", "name": "creative", "type": "public"},
|
||||
{"id": "strict", "name": "strict", "type": "public"},
|
||||
]
|
||||
for prompt in prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "private",
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", "owner", switches.get(pid)),
|
||||
}
|
||||
)
|
||||
for prompt in shared_prompts:
|
||||
pid = str(prompt["id"])
|
||||
list_prompts.append(
|
||||
{
|
||||
"id": str(prompt["id"]),
|
||||
"id": pid,
|
||||
"name": prompt["name"],
|
||||
"type": "team",
|
||||
"team_access": team_shared.get(str(prompt["id"])),
|
||||
"team_access": team_shared.get(pid),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**payload_for("prompt", team_shared.get(pid), switches.get(pid)),
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
@@ -115,19 +141,28 @@ class GetSinglePrompt(Resource):
|
||||
jsonify({"content": compose_preset(prompt_id)}), 200
|
||||
)
|
||||
with db_readonly() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(prompt_id, user)
|
||||
if not prompt and team_access_for(conn, user, "prompt", prompt_id):
|
||||
# Team fallback: ownerless fetch only after a grant check.
|
||||
prompt = repo.get_for_rendering(prompt_id)
|
||||
ra = require(conn, "prompt", prompt_id, user, "use")
|
||||
prompt = PromptsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}), 404
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(jsonify({"content": prompt["content"]}), 200)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"content": prompt["content"],
|
||||
"name": prompt.get("name"),
|
||||
"updated_at": _iso(prompt.get("updated_at")),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@prompts_ns.route("/delete_prompt")
|
||||
@@ -151,14 +186,12 @@ class DeletePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if not prompt:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
repo.delete(str(prompt["id"]), user)
|
||||
ra = require(conn, "prompt", data["id"], user, "delete")
|
||||
# Grants go with the row (delete trigger); switches have no FK.
|
||||
PromptsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "prompt", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -192,43 +225,26 @@ class UpdatePrompt(Resource):
|
||||
return missing_fields
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = PromptsRepository(conn)
|
||||
prompt = repo.get_any(data["id"], user)
|
||||
if prompt:
|
||||
repo.update(str(prompt["id"]), user, data["name"], data["content"])
|
||||
else:
|
||||
# Team editor write path (viewer is read-only).
|
||||
access = team_access_for(conn, user, "prompt", data["id"])
|
||||
if access == "editor":
|
||||
result = repo.update_by_id(
|
||||
data["id"],
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
elif access == "viewer":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Read-only: editor access required"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
else:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not found"}),
|
||||
404,
|
||||
)
|
||||
ra = require(conn, "prompt", data["id"], user, "edit")
|
||||
result = PromptsRepository(conn).update_by_id(
|
||||
ra.resource_id,
|
||||
data["name"],
|
||||
data["content"],
|
||||
expected_updated_at=data.get("expected_updated_at"),
|
||||
)
|
||||
if result is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Prompt was modified by someone else",
|
||||
"code": "stale_write",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return _denied(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""The one access check for team-shared resources: roles, actions and switches.
|
||||
|
||||
Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an
|
||||
owner and may be shared to teams as ``viewer`` or ``editor``. What each role
|
||||
may do is a fixed table of *actions* per resource type (``ACTIONS``). The
|
||||
owner can adjust a few of those rows on one resource with *switches*
|
||||
(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves
|
||||
one action between two roles; it never touches owner-only actions such as
|
||||
``manage_settings``.
|
||||
|
||||
Routes ask one question, ``require(conn, type, id, user, action)``, and get
|
||||
back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or
|
||||
an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the
|
||||
role may not do this). List and get responses embed ``ResourceAccess.payload()``
|
||||
(``access`` + ``allowed_actions``) so the frontend never re-derives the rules.
|
||||
|
||||
Access is resolved live on every call (grants JOIN ``team_members``), so a
|
||||
revoked grant or membership denies on the next request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
RESOURCE_TYPES = ("agent", "source", "tool", "prompt")
|
||||
|
||||
# Weakest role that may perform each action by default. ``owner`` rows are
|
||||
# owner-only unless a switch below moves them.
|
||||
ACTIONS: dict[str, dict[str, str]] = {
|
||||
"agent": {
|
||||
"use": "viewer", # chat with it
|
||||
"pin": "viewer",
|
||||
"view": "editor", # open the edit page and read its full config
|
||||
"edit": "editor",
|
||||
"publish": "editor",
|
||||
"edit_policy": "editor", # guardrails and quotas
|
||||
"view_logs": "editor",
|
||||
"manage_schedules": "editor",
|
||||
"export": "editor",
|
||||
"manage_access_details": "editor", # API key, webhook, public link
|
||||
"move_folder": "owner",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"source": {
|
||||
"use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents
|
||||
"view_config": "editor",
|
||||
"edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert
|
||||
"reconnect": "owner", # change the connector account
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"tool": {
|
||||
"use": "viewer", # see it and run it inside the owner's shared agents
|
||||
"use_in_own": "viewer", # add it to my own agents and chats
|
||||
"edit": "editor", # name, action descriptions, parameters, approval
|
||||
"edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
"prompt": {
|
||||
"use": "viewer", # read it and use it in my own agents
|
||||
"duplicate": "editor",
|
||||
"edit": "editor",
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Switch:
|
||||
"""One owner switch: moves ``action`` to ``role_on`` or ``role_off``."""
|
||||
|
||||
key: str
|
||||
default: bool
|
||||
action: str
|
||||
role_on: str
|
||||
role_off: str
|
||||
|
||||
|
||||
# Order is the order the share dialog lists them in.
|
||||
SWITCHES: dict[str, tuple[Switch, ...]] = {
|
||||
"agent": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"),
|
||||
Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"),
|
||||
),
|
||||
"source": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("editors_can_delete", False, "delete", "editor", "owner"),
|
||||
Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"),
|
||||
),
|
||||
"tool": (
|
||||
Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"),
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"),
|
||||
),
|
||||
"prompt": (
|
||||
Switch("editors_can_share", False, "share", "editor", "owner"),
|
||||
Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"),
|
||||
),
|
||||
}
|
||||
|
||||
_RANK = {"viewer": 1, "editor": 2, "owner": 3}
|
||||
|
||||
_REPO_FOR_TYPE = {
|
||||
"agent": AgentsRepository,
|
||||
"source": SourcesRepository,
|
||||
"prompt": PromptsRepository,
|
||||
"tool": UserToolsRepository,
|
||||
}
|
||||
|
||||
|
||||
class AccessDenied(Exception):
|
||||
"""Raised by :func:`require`; ``status`` is 404 (not visible) or 403."""
|
||||
|
||||
def __init__(self, status: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def default_settings(resource_type: str) -> dict[str, bool]:
|
||||
"""Every switch of ``resource_type`` at its default."""
|
||||
return {s.key: s.default for s in SWITCHES.get(resource_type, ())}
|
||||
|
||||
|
||||
def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]:
|
||||
merged = default_settings(resource_type)
|
||||
for key, value in (stored or {}).items():
|
||||
if key in merged and isinstance(value, bool):
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]:
|
||||
"""``action -> weakest role`` for one resource, switches applied."""
|
||||
table = dict(ACTIONS[resource_type])
|
||||
merged = _merge(resource_type, settings)
|
||||
for switch in SWITCHES.get(resource_type, ()):
|
||||
table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off
|
||||
return table
|
||||
|
||||
|
||||
def allowed_actions(
|
||||
resource_type: str, access: Optional[str], settings: Optional[dict]
|
||||
) -> set[str]:
|
||||
"""The actions ``access`` may perform on a resource with these switches."""
|
||||
if access not in _RANK or resource_type not in ACTIONS:
|
||||
return set()
|
||||
rank = _RANK[access]
|
||||
return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]}
|
||||
|
||||
|
||||
def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
|
||||
"""The switches as ``[{key, value, default}]`` in display order."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return [
|
||||
{"key": s.key, "value": merged[s.key], "default": s.default}
|
||||
for s in SWITCHES.get(resource_type, ())
|
||||
]
|
||||
|
||||
|
||||
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
|
||||
"""The resource's switches, defaults filled in."""
|
||||
return settings_many(conn, resource_type, [resource_id])[resource_id]
|
||||
|
||||
|
||||
def settings_many(
|
||||
conn: Connection, resource_type: str, resource_ids: Iterable[str]
|
||||
) -> dict[str, dict[str, bool]]:
|
||||
"""``resource_id -> switches`` for many resources in one query."""
|
||||
ids = [str(r) for r in resource_ids]
|
||||
out = {rid: default_settings(resource_type) for rid in ids}
|
||||
uuids = [rid for rid in ids if looks_like_uuid(rid)]
|
||||
if not uuids:
|
||||
return out
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT resource_id, settings FROM resource_share_settings
|
||||
WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "ids": uuids},
|
||||
).fetchall()
|
||||
for rid, stored in rows:
|
||||
out[str(rid)] = _merge(resource_type, stored)
|
||||
return out
|
||||
|
||||
|
||||
def set_settings(
|
||||
conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str
|
||||
) -> dict[str, bool]:
|
||||
"""Merge ``changes`` into the resource's switches and return the result.
|
||||
|
||||
Raises:
|
||||
ValueError: an unknown key or a non-boolean value.
|
||||
"""
|
||||
known = default_settings(resource_type)
|
||||
for key, value in changes.items():
|
||||
if key not in known:
|
||||
raise ValueError(f"Unknown setting: {key}")
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"Setting {key} must be true or false")
|
||||
merged = {**settings_for(conn, resource_type, resource_id), **changes}
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by)
|
||||
VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by)
|
||||
ON CONFLICT (resource_type, resource_id)
|
||||
DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by},
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None:
|
||||
"""Drop a deleted resource's switches (the table has no FK to cascade)."""
|
||||
if looks_like_uuid(resource_id):
|
||||
conn.execute(
|
||||
text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"),
|
||||
{"t": resource_type, "id": resource_id},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceAccess:
|
||||
"""What one user may do on one resource."""
|
||||
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
access: str # owner | editor | viewer
|
||||
owner_id: str # the id to read and write the resource as
|
||||
settings: dict = field(default_factory=dict)
|
||||
actions: frozenset = frozenset()
|
||||
|
||||
def can(self, action: str) -> bool:
|
||||
return action in self.actions
|
||||
|
||||
def payload(self) -> dict:
|
||||
"""The fields every API response embeds for this resource."""
|
||||
return {"access": self.access, "allowed_actions": sorted(self.actions)}
|
||||
|
||||
|
||||
def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess:
|
||||
"""A :class:`ResourceAccess` from already-known parts (list endpoints)."""
|
||||
merged = _merge(resource_type, settings)
|
||||
return ResourceAccess(
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id),
|
||||
access=access,
|
||||
owner_id=owner_id,
|
||||
settings=merged,
|
||||
actions=frozenset(allowed_actions(resource_type, access, merged)),
|
||||
)
|
||||
|
||||
|
||||
def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict:
|
||||
"""``access`` + ``allowed_actions`` without an owner lookup (list endpoints)."""
|
||||
return {
|
||||
"access": access,
|
||||
"allowed_actions": sorted(allowed_actions(resource_type, access, settings)),
|
||||
}
|
||||
|
||||
|
||||
def resolve(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str
|
||||
) -> Optional[ResourceAccess]:
|
||||
"""The caller's access to a resource, or None when they can't see it."""
|
||||
repo_cls = _REPO_FOR_TYPE.get(resource_type)
|
||||
if repo_cls is None or not resource_id or not user_id:
|
||||
return None
|
||||
owned = repo_cls(conn).get_any(str(resource_id), user_id)
|
||||
if owned is not None:
|
||||
rid = str(owned.get("id") or resource_id)
|
||||
return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid))
|
||||
# Only canonical UUIDs can carry a grant; casting anything else would
|
||||
# poison the transaction.
|
||||
if not looks_like_uuid(str(resource_id)):
|
||||
return None
|
||||
level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id))
|
||||
if level is None:
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id))
|
||||
if not grants:
|
||||
return None
|
||||
# Every grant row carries the same denormalised owner id.
|
||||
owner_id = grants[0].get("owner_id")
|
||||
return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id)))
|
||||
|
||||
|
||||
def require(
|
||||
conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str
|
||||
) -> ResourceAccess:
|
||||
"""Resolve and check one action.
|
||||
|
||||
Raises:
|
||||
KeyError: ``action`` is not an action of ``resource_type`` (a bug).
|
||||
AccessDenied: 404 when the resource isn't visible, 403 when the
|
||||
caller's role may not perform ``action``.
|
||||
"""
|
||||
if action not in ACTIONS.get(resource_type, {}):
|
||||
raise KeyError(f"{resource_type} has no action {action!r}")
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None:
|
||||
raise AccessDenied(404, f"{resource_type.capitalize()} not found")
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
return ra
|
||||
@@ -206,6 +206,33 @@ def _append_one_time_turn(
|
||||
return message
|
||||
|
||||
|
||||
def _scheduler_still_allowed(schedule: Dict[str, Any], agent_config: Dict[str, Any]) -> bool:
|
||||
"""Whether the schedule's user may still run this agent.
|
||||
|
||||
The run always executes as the agent's owner. A schedule stored under
|
||||
someone else (set from chat on a shared agent) needs that user to still
|
||||
see the agent — a live team grant, or a public link that is still on —
|
||||
so revoking a grant stops their schedules on the next tick.
|
||||
|
||||
Args:
|
||||
schedule: The schedule row.
|
||||
agent_config: The agent row (or the agentless ephemeral config).
|
||||
|
||||
Returns:
|
||||
True when the run may proceed.
|
||||
"""
|
||||
user_id = schedule.get("user_id")
|
||||
agent_id = agent_config.get("id")
|
||||
if not agent_id or not user_id or agent_config.get("user_id") == user_id:
|
||||
return True
|
||||
if agent_config.get("shared"):
|
||||
return True
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
|
||||
with get_engine().connect() as conn:
|
||||
return resolve(conn, "agent", str(agent_id), user_id) is not None
|
||||
|
||||
|
||||
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Execute one scheduled run by id; returns a result dict for tracing."""
|
||||
if not settings.POSTGRES_URI:
|
||||
@@ -256,6 +283,22 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di
|
||||
error="agent missing")
|
||||
return {"status": "failed", "reason": "agent missing"}
|
||||
|
||||
if not _scheduler_still_allowed(schedule, agent_config):
|
||||
with engine.begin() as conn:
|
||||
updated = ScheduleRunsRepository(conn).update(
|
||||
run_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"finished_at": datetime.now(timezone.utc),
|
||||
"error_type": "internal",
|
||||
"error": "agent access revoked",
|
||||
},
|
||||
)
|
||||
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
|
||||
_publish_run_event("schedule.run.failed", updated or run, schedule,
|
||||
error="agent access revoked")
|
||||
return {"status": "failed", "reason": "agent access revoked"}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
|
||||
return {"status": "skipped", "reason": "lost race to mark_running"}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
"""Schedules REST API (owner-scoped via request.decoded_token)."""
|
||||
"""Schedules REST API.
|
||||
|
||||
A schedule on an agent belongs to the agent's owner: it is stored (and runs)
|
||||
under the owner's ``user_id`` whoever creates it, and managing it needs
|
||||
``manage_schedules`` on the agent (owner and team editors). A schedule the
|
||||
caller made themselves (e.g. via the chat scheduler tool on a shared agent)
|
||||
stays theirs to manage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,6 +27,7 @@ from docsgpt.agents.scheduler_utils import (
|
||||
resolve_timezone,
|
||||
)
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -103,11 +111,70 @@ def _format_run(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _agent_for_schedules(agent_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""The agent row and the id its schedules live under.
|
||||
|
||||
Args:
|
||||
agent_id: Agent id from the URL.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(agent, owner_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when the agent isn't visible, 403 without
|
||||
``manage_schedules``.
|
||||
"""
|
||||
if not looks_like_uuid(str(agent_id)):
|
||||
return None
|
||||
raise AccessDenied(404, "agent not found")
|
||||
with db_readonly() as conn:
|
||||
return AgentsRepository(conn).get_any(agent_id, user_id)
|
||||
try:
|
||||
ra = require(conn, "agent", agent_id, user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
raise
|
||||
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
||||
if agent is None:
|
||||
raise AccessDenied(404, "agent not found")
|
||||
return agent, ra.owner_id
|
||||
|
||||
|
||||
def _schedule_for(conn, schedule_id: str, user_id: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Fetch a schedule the caller may manage, and the id to act as.
|
||||
|
||||
The caller's own schedule is always theirs. Otherwise it must be a
|
||||
schedule of an agent they hold ``manage_schedules`` on, stored under
|
||||
that agent's owner (another member's private schedule stays hidden).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
schedule_id: Schedule UUID.
|
||||
user_id: The caller.
|
||||
|
||||
Returns:
|
||||
``(schedule, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't manage it.
|
||||
"""
|
||||
row = SchedulesRepository(conn).get_internal(schedule_id)
|
||||
if row is None:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
if row.get("user_id") == user_id:
|
||||
return row, user_id
|
||||
agent_id = row.get("agent_id")
|
||||
if not agent_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
try:
|
||||
ra = require(conn, "agent", str(agent_id), user_id, "manage_schedules")
|
||||
except AccessDenied as denied:
|
||||
if denied.status == 404:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
raise
|
||||
if row.get("user_id") != ra.owner_id:
|
||||
raise AccessDenied(404, "schedule not found")
|
||||
return row, ra.owner_id
|
||||
|
||||
|
||||
def _user_id() -> Optional[str]:
|
||||
@@ -150,13 +217,14 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
rows = SchedulesRepository(conn).list_for_agent(
|
||||
str(agent["id"]), user_id,
|
||||
str(agent["id"]), owner_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error("list schedules failed: %s", exc, exc_info=True)
|
||||
@@ -195,9 +263,10 @@ class AgentSchedules(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
data = request.get_json(silent=True) or {}
|
||||
instruction = (data.get("instruction") or "").strip()
|
||||
tz_name = (data.get("timezone") or "UTC").strip() or "UTC"
|
||||
@@ -219,7 +288,7 @@ class AgentSchedules(Resource):
|
||||
except (TypeError, ValueError):
|
||||
return _err("token_budget must be a non-negative integer")
|
||||
with db_readonly() as conn:
|
||||
count = SchedulesRepository(conn).count_active_for_user(user_id)
|
||||
count = SchedulesRepository(conn).count_active_for_user(owner_id)
|
||||
if (
|
||||
settings.SCHEDULE_MAX_PER_USER > 0
|
||||
and count >= settings.SCHEDULE_MAX_PER_USER
|
||||
@@ -240,7 +309,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="once",
|
||||
instruction=instruction,
|
||||
@@ -295,7 +364,7 @@ class AgentSchedules(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
created = SchedulesRepository(conn).create(
|
||||
user_id=user_id,
|
||||
user_id=owner_id,
|
||||
agent_id=str(agent["id"]),
|
||||
trigger_type="recurring",
|
||||
instruction=instruction,
|
||||
@@ -337,9 +406,10 @@ class AgentScheduleStats(Resource):
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
agent, owner_id = _agent_for_schedules(agent_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
try:
|
||||
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||
except (TypeError, ValueError):
|
||||
@@ -347,7 +417,7 @@ class AgentScheduleStats(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
stats = ScheduleRunsRepository(conn).stats_for_agent(
|
||||
str(agent["id"]), user_id, days=days,
|
||||
str(agent["id"]), owner_id, days=days,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error(
|
||||
@@ -377,9 +447,10 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_readonly() as conn:
|
||||
row = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if row is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
row, _acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
return _ok({"schedule": _format_schedule(row)})
|
||||
|
||||
@api.doc(description="Edit a schedule's editable fields.")
|
||||
@@ -439,9 +510,10 @@ class ScheduleResource(Resource):
|
||||
fields_in["end_at"] = None
|
||||
# Recompute next_run_at when cron/tz changes.
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if (
|
||||
("cron" in fields_in or "timezone" in fields_in)
|
||||
and existing.get("trigger_type") == "recurring"
|
||||
@@ -467,7 +539,7 @@ class ScheduleResource(Resource):
|
||||
except ScheduleValidationError as exc:
|
||||
return _err(str(exc))
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -484,9 +556,10 @@ class ScheduleResource(Resource):
|
||||
if action not in {"pause", "resume"}:
|
||||
return _err("action must be 'pause' or 'resume'")
|
||||
with db_session() as conn:
|
||||
existing = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if existing is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
existing, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
if existing.get("status") in ("cancelled", "completed"):
|
||||
return _err("schedule is terminal", 409)
|
||||
if action == "pause":
|
||||
@@ -538,13 +611,13 @@ class ScheduleResource(Resource):
|
||||
)
|
||||
fields_in["next_run_at"] = run_at_dt
|
||||
updated = SchedulesRepository(conn).update(
|
||||
schedule_id, user_id, fields_in,
|
||||
schedule_id, acting, fields_in,
|
||||
)
|
||||
if action == "resume":
|
||||
SchedulesRepository(conn).reset_failure_count(schedule_id)
|
||||
if action == "resume" and updated:
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.resumed", schedule_id, status="active",
|
||||
acting, "schedule.resumed", schedule_id, status="active",
|
||||
)
|
||||
return _ok({"schedule": _format_schedule(updated or {})})
|
||||
|
||||
@@ -557,11 +630,15 @@ class ScheduleResource(Resource):
|
||||
if not looks_like_uuid(schedule_id):
|
||||
return _err("invalid schedule id", 400)
|
||||
with db_session() as conn:
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, user_id)
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
ok = SchedulesRepository(conn).delete(schedule_id, acting)
|
||||
if not ok:
|
||||
return _err("schedule not found", 404)
|
||||
_publish_schedule_event(
|
||||
user_id, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
acting, "schedule.cancelled", schedule_id, status="cancelled",
|
||||
)
|
||||
return _ok({"success": True})
|
||||
|
||||
@@ -579,8 +656,12 @@ class ScheduleRunNow(Resource):
|
||||
# FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique
|
||||
# scheduled_for values would otherwise sneak past the unique index).
|
||||
with db_session() as conn:
|
||||
try:
|
||||
_row, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
schedule = SchedulesRepository(conn).get_for_update(
|
||||
schedule_id, user_id,
|
||||
schedule_id, acting,
|
||||
)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
@@ -590,9 +671,10 @@ class ScheduleRunNow(Resource):
|
||||
return _err("a run is already in flight", 409)
|
||||
scheduled_for = datetime.now(timezone.utc)
|
||||
agent_id_raw = schedule.get("agent_id")
|
||||
# The run belongs to (and executes as) the schedule's owner.
|
||||
run = ScheduleRunsRepository(conn).record_pending(
|
||||
schedule_id,
|
||||
user_id,
|
||||
acting,
|
||||
str(agent_id_raw) if agent_id_raw else None,
|
||||
scheduled_for,
|
||||
trigger_source="manual",
|
||||
@@ -633,11 +715,12 @@ class ScheduleRunList(Resource):
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
try:
|
||||
_schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
rows = ScheduleRunsRepository(conn).list_runs(
|
||||
schedule_id, user_id, limit=limit, offset=offset,
|
||||
schedule_id, acting, limit=limit, offset=offset,
|
||||
)
|
||||
return _ok(
|
||||
{
|
||||
@@ -659,10 +742,11 @@ class ScheduleRunDetail(Resource):
|
||||
if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id):
|
||||
return _err("invalid id", 400)
|
||||
with db_readonly() as conn:
|
||||
schedule = SchedulesRepository(conn).get(schedule_id, user_id)
|
||||
if schedule is None:
|
||||
return _err("schedule not found", 404)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, user_id)
|
||||
try:
|
||||
schedule, acting = _schedule_for(conn, schedule_id, user_id)
|
||||
except AccessDenied as denied:
|
||||
return _err(denied.message, denied.status)
|
||||
run = ScheduleRunsRepository(conn).get(run_id, acting)
|
||||
if run is None or str(run.get("schedule_id")) != str(
|
||||
schedule["id"]
|
||||
):
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Role checks shared by the source routes (sources, chunks, upload, search).
|
||||
|
||||
Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source
|
||||
endpoint resolves the row the same way and answers denials with the same
|
||||
JSON shape: 404 when the caller can't see the source, 403 when they can but
|
||||
their role may not perform the action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, make_response
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
|
||||
def load_source(
|
||||
conn: Connection, source_id: Optional[str], user: str, action: str
|
||||
) -> tuple[dict, ResourceAccess]:
|
||||
"""Check ``action`` on a source and return its row with the caller's access.
|
||||
|
||||
The row is fetched by the canonical id only after the check passes, so a
|
||||
team member gets the owner's row without ownership scoping.
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
source_id: Source id from the request (UUID or legacy id).
|
||||
user: The caller's ``sub``.
|
||||
action: A ``source`` action from ``resource_access.ACTIONS``.
|
||||
|
||||
Returns:
|
||||
tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
if not source_id:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
ra = require(conn, "source", str(source_id), user, action)
|
||||
doc = SourcesRepository(conn).get_by_id(ra.resource_id)
|
||||
if doc is None:
|
||||
raise AccessDenied(404, "Source not found")
|
||||
return doc, ra
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""The JSON error response for an :class:`AccessDenied`."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": err.message}), err.status
|
||||
)
|
||||
@@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import get_vector_store
|
||||
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
from docsgpt.utils import check_required_fields, num_tokens_from_string
|
||||
from docsgpt.vectorstore.base import InvalidChunkMetadataError
|
||||
@@ -18,38 +18,27 @@ sources_chunks_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source(doc_id: str, user: str):
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
|
||||
def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict:
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Returns the row
|
||||
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
|
||||
``use`` (browse chunks) is open to every role; ``edit`` (add / delete /
|
||||
update chunks) needs owner or team editor. The vector partition is keyed
|
||||
by source id, so a team editor's write needs no owner id.
|
||||
|
||||
Args:
|
||||
doc_id: Source id from the request.
|
||||
user: The caller's ``sub``.
|
||||
action: ``use`` for reads, ``edit`` for chunk writes.
|
||||
|
||||
Returns:
|
||||
dict: The source row (PG UUID in ``id``).
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't do it.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", doc_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(doc_id)
|
||||
|
||||
|
||||
def _resolve_source_for_write(doc_id: str, user: str):
|
||||
"""Resolve a source the caller may WRITE chunks on.
|
||||
|
||||
Returns the row dict when ``user`` owns the source, or when they hold a
|
||||
team ``editor`` grant (adding/removing/editing documents is editor-allowed
|
||||
— the vector partition is keyed by source_id, owner-agnostic). Returns
|
||||
``None`` for viewer-only / no access. Source deletion stays owner-only and
|
||||
is handled elsewhere.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
owner = effective_write_owner(conn, "source", doc_id, user)
|
||||
if not owner:
|
||||
return None
|
||||
return SourcesRepository(conn).get_any(doc_id, owner)
|
||||
doc, _ra = load_source(conn, doc_id, user, action)
|
||||
return doc
|
||||
|
||||
|
||||
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
@@ -193,13 +182,11 @@ class GetChunks(Resource):
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
try:
|
||||
doc = _resolve_source(doc_id, user)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
try:
|
||||
store = get_vector_store(resolved_id)
|
||||
@@ -278,12 +265,12 @@ class AddChunk(Resource):
|
||||
metadata["token_count"] = token_count
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
chunk_id = store.add_chunk(text, metadata)
|
||||
@@ -311,12 +298,12 @@ class DeleteChunk(Resource):
|
||||
chunk_id = request.args.get("chunk_id")
|
||||
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
@@ -378,12 +365,12 @@ class UpdateChunk(Resource):
|
||||
metadata = {}
|
||||
metadata["token_count"] = token_count
|
||||
try:
|
||||
doc = _resolve_source_for_write(doc_id, user)
|
||||
doc = _resolve_source(doc_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"error": "Source not accessible"}), 403)
|
||||
try:
|
||||
store = get_vector_store(str(doc["id"]))
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource
|
||||
from pydantic import ValidationError
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.sources.routes import _resolve_readable_source
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.model_utils import get_default_model_id
|
||||
from docsgpt.retriever.dispatcher import Dispatcher
|
||||
from docsgpt.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -102,21 +103,16 @@ class SourceSearch(Resource):
|
||||
# Read access = owner or any team grant (viewer included), matching
|
||||
# the other source read endpoints (wiki pages, graph).
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
# An unresolvable id yields None (→ 404); reaching here means the
|
||||
# An unresolvable id is AccessDenied (404); reaching here means the
|
||||
# lookup itself failed, which is ours, not the caller's.
|
||||
logger.error(f"Error resolving source: {e}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not resolve source"}), 500
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not found or access denied"}
|
||||
),
|
||||
404,
|
||||
)
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
# A supplied config is validated exactly as strictly as a saved one (D7
|
||||
|
||||
+142
-177
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import math
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app, jsonify, make_response, redirect, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
@@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import (
|
||||
reingest_source_task,
|
||||
sync_source,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import (
|
||||
can_access,
|
||||
effective_write_owner,
|
||||
visible_with_access,
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.graphrag import graphrag_available
|
||||
from docsgpt.parser.remote.remote_creator import normalize_remote_data
|
||||
@@ -70,6 +74,28 @@ def _get_provider_from_remote_data(remote_data):
|
||||
return None
|
||||
|
||||
|
||||
def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict:
|
||||
"""Add ``access`` + ``allowed_actions`` to a listed source row.
|
||||
|
||||
The source's behaviour ``config`` is dropped when the caller's role may
|
||||
not ``view_config`` (a viewer when the owner turned
|
||||
``viewers_can_see_config`` off).
|
||||
|
||||
Args:
|
||||
entry: The row as the list endpoint builds it.
|
||||
access: ``owner`` / ``editor`` / ``viewer``.
|
||||
switches: The source's owner switches (``settings_many`` output).
|
||||
|
||||
Returns:
|
||||
dict: ``entry`` with the access payload merged in.
|
||||
"""
|
||||
payload = payload_for("source", access, switches)
|
||||
if "view_config" not in payload["allowed_actions"]:
|
||||
entry.pop("config", None)
|
||||
entry.update(payload)
|
||||
return entry
|
||||
|
||||
|
||||
@sources_ns.route("/sources")
|
||||
class CombinedJson(Resource):
|
||||
@api.doc(description="Provide JSON file with combined available indexes")
|
||||
@@ -93,6 +119,7 @@ class CombinedJson(Resource):
|
||||
team_shared = visible_with_access(conn, user, "source")
|
||||
shared_ids = [sid for sid in team_shared if sid not in owned_ids]
|
||||
shared_sources = repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "source", [*owned_ids, *shared_ids])
|
||||
# list_for_user sorts by created_at DESC; legacy shape sorted by
|
||||
# "date" DESC. Both are monotonic on creation so the ordering is
|
||||
# equivalent for dev; re-sort defensively.
|
||||
@@ -103,7 +130,7 @@ class CombinedJson(Resource):
|
||||
|
||||
def _source_entry(index, *, ownership="user", team_access=None):
|
||||
provider = _get_provider_from_remote_data(index.get("remote_data"))
|
||||
return {
|
||||
entry = {
|
||||
"id": str(index["id"]),
|
||||
"name": index.get("name"),
|
||||
"date": index.get("date"),
|
||||
@@ -121,6 +148,11 @@ class CombinedJson(Resource):
|
||||
"ownership": ownership,
|
||||
"team_access": team_access,
|
||||
}
|
||||
return _with_access(
|
||||
entry,
|
||||
"owner" if ownership == "user" else team_access,
|
||||
switches.get(str(index["id"])),
|
||||
)
|
||||
|
||||
for index in indexes:
|
||||
data.append(_source_entry(index))
|
||||
@@ -178,6 +210,9 @@ class PaginatedSources(Resource):
|
||||
sort_order=sort_order,
|
||||
extra_ids=extra_ids,
|
||||
)
|
||||
switches = settings_many(
|
||||
conn, "source", [str(doc["id"]) for doc in window]
|
||||
)
|
||||
|
||||
paginated_docs = []
|
||||
for doc in window:
|
||||
@@ -185,32 +220,37 @@ class PaginatedSources(Resource):
|
||||
# Owner vs team-shared: a row in the window is the caller's own
|
||||
# when its user_id matches; otherwise it arrived via extra_ids.
|
||||
owned = str(doc.get("user_id")) == str(user)
|
||||
entry = {
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# Derived in SourcesRepository.list_for_user.
|
||||
"ingestStatus": doc.get("ingest_status"),
|
||||
"ownership": "user" if owned else "team",
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
}
|
||||
paginated_docs.append(
|
||||
{
|
||||
"id": str(doc["id"]),
|
||||
"name": doc.get("name", ""),
|
||||
"date": doc.get("date", ""),
|
||||
"model": settings.EMBEDDINGS_NAME,
|
||||
"location": "local",
|
||||
"tokens": doc.get("tokens", ""),
|
||||
"retriever": doc.get("retriever", "classic"),
|
||||
"syncFrequency": doc.get("sync_frequency", ""),
|
||||
"provider": provider,
|
||||
"isNested": bool(doc.get("directory_structure")),
|
||||
"type": doc.get("type", "file"),
|
||||
# Lenient read (D7): always emit a fully-defaulted
|
||||
# config so the edit modal can pre-fill, even for a
|
||||
# legacy {} row.
|
||||
"config": SourceConfig.parse(
|
||||
doc.get("config")
|
||||
).model_dump(),
|
||||
# Derived in SourcesRepository.list_for_user.
|
||||
"ingestStatus": doc.get("ingest_status"),
|
||||
"ownership": "user" if owned else "team",
|
||||
"team_access": (
|
||||
None if owned else team_shared.get(str(doc["id"]))
|
||||
),
|
||||
}
|
||||
_with_access(
|
||||
entry,
|
||||
"owner" if owned else team_shared.get(str(doc["id"])),
|
||||
switches.get(str(doc["id"])),
|
||||
)
|
||||
)
|
||||
response = {
|
||||
"total": total_documents,
|
||||
@@ -242,14 +282,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing required fields"}), 400
|
||||
)
|
||||
# Owner-only unless the owner turned ``editors_can_delete`` on; the
|
||||
# row is deleted as the owner either way.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
doc, ra = load_source(conn, source_id, user, "delete")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
if not doc:
|
||||
return make_response(jsonify({"status": "not found"}), 404)
|
||||
owner = ra.owner_id
|
||||
storage = StorageCreator.get_storage()
|
||||
resolved_id = str(doc["id"])
|
||||
|
||||
@@ -283,13 +326,17 @@ class DeleteOldIndexes(Resource):
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
SourcesRepository(conn).delete(resolved_id, user)
|
||||
# The AFTER DELETE trigger drops the source's team grants; the
|
||||
# owner switches have no FK, so clear them here.
|
||||
SourcesRepository(conn).delete(resolved_id, owner)
|
||||
delete_settings(conn, "source", resolved_id)
|
||||
record_event(
|
||||
conn,
|
||||
"source.deleted",
|
||||
actor=user,
|
||||
source_id=resolved_id,
|
||||
name=doc.get("name"),
|
||||
owner=owner if owner != user else None,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -342,21 +389,13 @@ class ManageSync(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
doc = repo.get_any(source_id, user)
|
||||
if doc is not None:
|
||||
repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency})
|
||||
else:
|
||||
# Team editor write path (sync_frequency is metadata, no
|
||||
# ingestion side effects). Reingest/sync triggers stay
|
||||
# owner-only pending a cost/side-effect decision.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(source_id, owner, {"sync_frequency": sync_frequency})
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
SourcesRepository(conn).update(
|
||||
str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating sync frequency: {err}", exc_info=True
|
||||
@@ -391,21 +430,15 @@ class SyncSource(Resource):
|
||||
# source_id (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error looking up source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
source_type = doc.get("type", "")
|
||||
if source_type and source_type.startswith("connector"):
|
||||
return make_response(
|
||||
@@ -469,12 +502,9 @@ class ReingestSource(Resource):
|
||||
# (owner-agnostic), so dispatching as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if doc is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error looking up source: {err}", exc_info=True
|
||||
@@ -482,10 +512,7 @@ class ReingestSource(Resource):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid source ID"}), 400
|
||||
)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# Drop the stale chunk-progress row so the sources list stops
|
||||
# deriving a 'failed' status; reingest never rewrites it itself.
|
||||
@@ -548,11 +575,7 @@ class DirectoryStructure(Resource):
|
||||
return make_response(jsonify({"error": "Document ID is required"}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, doc_id, user)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
)
|
||||
doc, _ra = load_source(conn, doc_id, user, "use")
|
||||
directory_structure = doc.get("directory_structure", {})
|
||||
base_path = doc.get("file_path", "")
|
||||
|
||||
@@ -579,6 +602,8 @@ class DirectoryStructure(Resource):
|
||||
),
|
||||
200,
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
f"Error retrieving directory structure: {e}", exc_info=True
|
||||
@@ -636,23 +661,9 @@ class SourceConfigResource(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = SourcesRepository(conn)
|
||||
# Resolve the owner to write AS: ``user`` when they own the
|
||||
# source, the real owner when ``user`` holds a team ``editor``
|
||||
# grant. A viewer / no-access resolves to None → 403.
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = repo.get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; the write lands as the owner.
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
# Ingest-time fields (config.chunking) only take effect after a
|
||||
# re-ingest (D8); compare against the current config to decide.
|
||||
current_config = SourceConfig.parse(doc.get("config"))
|
||||
@@ -683,6 +694,8 @@ class SourceConfigResource(Resource):
|
||||
repo.update(
|
||||
str(doc["id"]), owner, {"config": new_config.model_dump()}
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating source config for {source_id}: {err}", exc_info=True
|
||||
@@ -734,20 +747,6 @@ def _unsupported_retrieval_warnings(config) -> list:
|
||||
return warnings
|
||||
|
||||
|
||||
def _resolve_readable_source(conn, source_id, user):
|
||||
"""Return a source dict the caller may READ, or None.
|
||||
|
||||
Read access = owner or any team grant (viewer/editor). Resolves the row
|
||||
without ownership scoping only after the grant check passes.
|
||||
"""
|
||||
doc = SourcesRepository(conn).get_any(source_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", source_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(source_id)
|
||||
|
||||
|
||||
def _wiki_page_node(page):
|
||||
return {
|
||||
"path": page.get("path"),
|
||||
@@ -886,12 +885,10 @@ class WikiPages(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
pages = WikiPagesRepository(conn).list_for_source(str(doc["id"]))
|
||||
directory_structure = doc.get("directory_structure") or {}
|
||||
except Exception as err:
|
||||
@@ -934,12 +931,10 @@ class WikiPage(Resource):
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -977,20 +972,12 @@ class WikiPage(Resource):
|
||||
expected_version = data.get("expected_version")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
try:
|
||||
page = WikiPagesRepository(conn).upsert(
|
||||
@@ -1074,20 +1061,12 @@ class ConvertSourceToWiki(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
# A mid-ingest source has an incomplete directory structure, so
|
||||
# it could be mis-detected as blank and wrongly enabled inline.
|
||||
@@ -1194,20 +1173,12 @@ class EnableSourceGraphRAG(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Source not accessible"}
|
||||
),
|
||||
403,
|
||||
)
|
||||
doc = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
# Owner or team editor; writes and re-embeds run as the owner.
|
||||
try:
|
||||
doc, ra = load_source(conn, source_id, user, "edit")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
owner = ra.owner_id
|
||||
resolved_source_id = str(doc["id"])
|
||||
cfg = SourceConfig.parse(doc.get("config"))
|
||||
repo = SourcesRepository(conn)
|
||||
@@ -1314,12 +1285,10 @@ class SourceGraph(Resource):
|
||||
limit = None
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1449,12 +1418,10 @@ class SourceGraphNodes(Resource):
|
||||
type_key = request.args.get("type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
@@ -1500,12 +1467,10 @@ class SourceGraphNode(Resource):
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
try:
|
||||
doc, _ra = load_source(conn, source_id, user, "use")
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
|
||||
@@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.audit import record_event
|
||||
from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.api.user.resource_access import AccessDenied
|
||||
from docsgpt.api.user.sources.access import denied_response, load_source
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id
|
||||
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
|
||||
@@ -721,22 +722,10 @@ class ManageSourceFiles(Resource):
|
||||
# (owner-agnostic), so running the ops as the owner is correct.
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
source = SourcesRepository(conn).get_any(source_id, user)
|
||||
owner = user
|
||||
if source is None:
|
||||
owner = effective_write_owner(conn, "source", source_id, user)
|
||||
if owner:
|
||||
source = SourcesRepository(conn).get_any(source_id, owner)
|
||||
if not source:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Source not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
source, ra = load_source(conn, source_id, user, "edit")
|
||||
owner = ra.owner_id
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
|
||||
return make_response(
|
||||
|
||||
@@ -13,13 +13,10 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy import Connection
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.api.user.resource_access import resolve
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.prompts import PromptsRepository
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
@@ -89,22 +86,14 @@ def effective_write_owner(
|
||||
...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without
|
||||
a separate ownerless write path. None means viewer-only or no access → the
|
||||
route should answer 403/404. Delete is never authorized here — owner-only.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`; new code should call
|
||||
``resource_access.require`` with a specific action instead.
|
||||
"""
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return user_id
|
||||
# Past the ownership check, only canonical-UUID resources can carry a team
|
||||
# grant; a legacy/non-UUID id can't, and casting it would poison the txn.
|
||||
if not looks_like_uuid(resource_id):
|
||||
ra = resolve(conn, resource_type, resource_id, user_id)
|
||||
if ra is None or ra.access not in ("owner", "editor"):
|
||||
return None
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
if not grants:
|
||||
return None
|
||||
if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id):
|
||||
# All grant rows carry the same denormalised owner_id.
|
||||
return grants[0].get("owner_id")
|
||||
return None
|
||||
return ra.owner_id
|
||||
|
||||
|
||||
def can_access(
|
||||
@@ -116,9 +105,9 @@ def can_access(
|
||||
``source_id`` to an agent): you may reference what you own or what a team has
|
||||
shared with you directly. Transitive access *through* a shared agent is a
|
||||
separate, run-time concept and is intentionally NOT gated here.
|
||||
|
||||
A thin wrapper over :func:`resource_access.resolve`.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owns_resource(conn, resource_type, resource_id, user_id):
|
||||
return True
|
||||
return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id)
|
||||
return resolve(conn, resource_type, resource_id, user_id) is not None
|
||||
@@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``):
|
||||
- Team detail / member list / grant list require team membership.
|
||||
- Member management and team edit require ``team_admin``.
|
||||
- Team deletion and owner transfer are owner-only (a global ``admin`` overrides).
|
||||
- Sharing a resource requires the caller to OWN it (dispatched by resource_type)
|
||||
and be a member of the target team. Sharing is additive visibility — the
|
||||
resource's owner is never changed.
|
||||
- Sharing a resource requires the ``share`` action on it (the owner, or an
|
||||
editor when the owner turned on ``editors_can_share``; see
|
||||
``resource_access.py``) and membership of the target team. Unsharing needs
|
||||
``share`` (no membership required) or ``team_admin`` of the team. Sharing is
|
||||
additive visibility — the resource's owner is never changed.
|
||||
- The team owner can't be demoted or removed; they transfer ownership first.
|
||||
|
||||
``team_id`` always comes from the URL path (never the body) — enforced by
|
||||
``require_team_role`` and by reading ``team_id`` as a route kwarg here.
|
||||
@@ -22,14 +25,25 @@ import uuid
|
||||
|
||||
from flask import jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text
|
||||
|
||||
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
|
||||
from docsgpt.api.user.resource_access import (
|
||||
RESOURCE_TYPES,
|
||||
AccessDenied,
|
||||
ResourceAccess,
|
||||
build,
|
||||
public_settings,
|
||||
require,
|
||||
set_settings,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_authz import (
|
||||
has_team_role,
|
||||
team_admin_required,
|
||||
team_member_required,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource
|
||||
from docsgpt.api.user.team_sharing import is_valid_resource_type
|
||||
from docsgpt.events.publisher import publish_user_event
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
@@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import (
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
@@ -62,6 +77,99 @@ def _current_user() -> str | None:
|
||||
return token.get("sub") if isinstance(token, dict) else None
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _team_payload(team: dict, user: str | None) -> dict:
|
||||
"""Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer)."""
|
||||
team["is_owner"] = bool(user) and team.get("owner_id") == user
|
||||
return team
|
||||
|
||||
|
||||
# Name + owner of each shareable resource, looked up unscoped by id (the grant
|
||||
# row already proves it was shared; the caller's own access is computed below).
|
||||
_RESOURCE_ROW_SQL = {
|
||||
"agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))",
|
||||
"tool": (
|
||||
"SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, "
|
||||
"user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]:
|
||||
"""``(type, id) -> {name, user_id}`` for every resource the grants point at."""
|
||||
ids_by_type: dict[str, set[str]] = {}
|
||||
for g in grants:
|
||||
ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"]))
|
||||
out: dict[tuple[str, str], dict] = {}
|
||||
for rtype, ids in ids_by_type.items():
|
||||
sql = _RESOURCE_ROW_SQL.get(rtype)
|
||||
if not sql:
|
||||
continue
|
||||
for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall():
|
||||
out[(rtype, str(rid))] = {"name": name, "user_id": owner}
|
||||
return out
|
||||
|
||||
|
||||
def _caller_access(
|
||||
conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict]
|
||||
) -> dict[tuple[str, str], ResourceAccess]:
|
||||
"""The caller's live access to each granted resource, in a few bulk queries.
|
||||
|
||||
Same answer as ``resource_access.resolve`` per resource (owner by the
|
||||
resource's ``user_id``, else the strongest team grant reaching the caller),
|
||||
without four queries per row.
|
||||
"""
|
||||
scope = TeamScopeRepository(conn)
|
||||
out: dict[tuple[str, str], ResourceAccess] = {}
|
||||
for rtype in {g["resource_type"] for g in grants}:
|
||||
ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype})
|
||||
via_teams = scope.visible_with_access(user, rtype)
|
||||
settings = settings_many(conn, rtype, ids)
|
||||
for rid in ids:
|
||||
row = rows.get((rtype, rid))
|
||||
if row is None:
|
||||
continue # dangling grant: the resource is gone
|
||||
if row["user_id"] == user:
|
||||
level = "owner"
|
||||
else:
|
||||
level = via_teams.get(rid)
|
||||
if level is None:
|
||||
continue
|
||||
out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid])
|
||||
return out
|
||||
|
||||
|
||||
def _user_labels(conn, user_ids: set[str]) -> dict[str, str]:
|
||||
"""``user_id -> email`` for the users on file with an email."""
|
||||
ids = [u for u in user_ids if u]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
|
||||
"AND email IS NOT NULL AND email <> ''"
|
||||
),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {uid: email for uid, email in rows}
|
||||
|
||||
|
||||
def _valid_resource(resource_type, resource_id) -> bool:
|
||||
"""A known shareable type and a canonical-UUID id (grants are UUID-only)."""
|
||||
return (
|
||||
is_valid_resource_type(resource_type)
|
||||
and bool(resource_id)
|
||||
and isinstance(resource_id, str)
|
||||
and looks_like_uuid(resource_id)
|
||||
)
|
||||
|
||||
|
||||
# Metadata keys naming the user a team event acted on, most specific first.
|
||||
# Lets ``_audit`` fill ``target_id`` without every call site repeating it.
|
||||
_TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner")
|
||||
@@ -218,6 +326,7 @@ class Teams(Resource):
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
teams = TeamsRepository(conn).list_for_user(user)
|
||||
teams = [_team_payload(t, user) for t in teams]
|
||||
return make_response(jsonify({"success": True, "teams": teams}), 200)
|
||||
except Exception as err:
|
||||
logger.error("List teams failed: %s", err, exc_info=True)
|
||||
@@ -243,6 +352,7 @@ class Teams(Resource):
|
||||
)
|
||||
_audit(conn, user, "team.create", team_id=team["id"], name=name)
|
||||
team["member_role"] = ROLE_TEAM_ADMIN
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 201)
|
||||
except Exception as err:
|
||||
logger.error("Create team failed: %s", err, exc_info=True)
|
||||
@@ -263,6 +373,7 @@ class Team(Resource):
|
||||
members = TeamMembersRepository(conn)
|
||||
team["members"] = members.list_members(team_id)
|
||||
team["member_role"] = members.role_for(user, team_id)
|
||||
_team_payload(team, user)
|
||||
return make_response(jsonify({"success": True, "team": team}), 200)
|
||||
except Exception as err:
|
||||
logger.error("Get team failed: %s", err, exc_info=True)
|
||||
@@ -370,6 +481,10 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "invalid role"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
if role == ROLE_TEAM_MEMBER:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "demote")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if role == ROLE_TEAM_MEMBER and self._would_orphan_admins(
|
||||
members, team_id, member_id
|
||||
@@ -403,6 +518,9 @@ class TeamMember(Resource):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
with db_session() as conn:
|
||||
blocked = self._owner_guard(conn, team_id, member_id, "remove")
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
members = TeamMembersRepository(conn)
|
||||
if self._would_orphan_admins(members, team_id, member_id):
|
||||
return {
|
||||
@@ -423,6 +541,38 @@ class TeamMember(Resource):
|
||||
logger.error("Remove member failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@staticmethod
|
||||
def _owner_guard(conn, team_id: str, member_id: str, change: str):
|
||||
"""Refuse to demote or remove the team owner.
|
||||
|
||||
Another admin gets 403; the owner themselves gets 400 telling them to
|
||||
transfer ownership first (the team would otherwise have an owner who
|
||||
isn't an admin, or isn't a member at all).
|
||||
|
||||
Args:
|
||||
conn: Open connection.
|
||||
team_id: Team from the URL path.
|
||||
member_id: The member being changed.
|
||||
change: ``"demote"`` or ``"remove"`` (for the message).
|
||||
|
||||
Returns:
|
||||
A response to return, or None when the change may proceed.
|
||||
"""
|
||||
team = TeamsRepository(conn).get(team_id)
|
||||
if not team or team.get("owner_id") != member_id:
|
||||
return None
|
||||
if member_id == _current_user():
|
||||
message = (
|
||||
"Transfer team ownership to another member before you leave the team"
|
||||
if change == "remove"
|
||||
else "Transfer team ownership to another member before you step down as admin"
|
||||
)
|
||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||
verb = "removed" if change == "remove" else "demoted"
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _would_orphan_admins(
|
||||
members: TeamMembersRepository, team_id: str, member_id: str
|
||||
@@ -443,21 +593,66 @@ class TeamMember(Resource):
|
||||
class TeamGrants(Resource):
|
||||
@team_member_required
|
||||
def get(self, team_id):
|
||||
"""List resources shared with this team. Requires membership."""
|
||||
"""List resources shared with this team. Requires membership.
|
||||
|
||||
Each row carries the resource's name, owner and people labels (email
|
||||
when on file, else null) and ``caller`` — the caller's own live
|
||||
``{access, allowed_actions}`` on that resource (null if none). A plain
|
||||
member sees whole-team grants and grants aimed at them; a team_admin,
|
||||
or someone with ``share`` on the resource, sees every grant.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
resource_type = request.args.get("resource_type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
grants = TeamResourceGrantsRepository(conn).list_for_team(
|
||||
team_id, resource_type
|
||||
)
|
||||
return make_response(jsonify({"success": True, "grants": grants}), 200)
|
||||
team_role = TeamMembersRepository(conn).role_for(user, team_id)
|
||||
sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN)
|
||||
rows = _resource_rows(conn, grants)
|
||||
access = _caller_access(conn, user, grants, rows)
|
||||
visible = []
|
||||
for g in grants:
|
||||
key = (g["resource_type"], str(g["resource_id"]))
|
||||
ra = access.get(key)
|
||||
target = g.get("target_user_id")
|
||||
if not (sees_all or not target or target == user or (ra and ra.can("share"))):
|
||||
continue
|
||||
row = rows.get(key) or {}
|
||||
g["resource_name"] = row.get("name")
|
||||
g["owner_id"] = row.get("user_id") or g.get("owner_id")
|
||||
g["caller"] = ra.payload() if ra else None
|
||||
visible.append(g)
|
||||
labels = _user_labels(
|
||||
conn,
|
||||
{
|
||||
u
|
||||
for g in visible
|
||||
for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by"))
|
||||
if u
|
||||
},
|
||||
)
|
||||
for g in visible:
|
||||
g["owner_label"] = labels.get(g.get("owner_id"))
|
||||
g["target_user_label"] = labels.get(g.get("target_user_id"))
|
||||
g["granted_by_label"] = labels.get(g.get("granted_by"))
|
||||
return make_response(
|
||||
jsonify({"success": True, "grants": visible, "team_role": team_role}), 200
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error("List grants failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def post(self, team_id):
|
||||
"""Share a resource the caller OWNS with this team (additive visibility)."""
|
||||
"""Share a resource with this team, or change an existing grant's level.
|
||||
|
||||
Needs ``share`` on the resource (the owner, or an editor when the owner
|
||||
turned on ``editors_can_share``) and membership of the team. The grant
|
||||
records the real owner as ``owner_id`` and the caller as ``granted_by``.
|
||||
"""
|
||||
user = _current_user()
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
@@ -465,20 +660,18 @@ class TeamGrants(Resource):
|
||||
access_level = data.get("access_level", "viewer")
|
||||
# None → share with the whole team; a sub → share with that one member.
|
||||
target_user_id = (data.get("target_user_id") or "").strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if access_level not in _VALID_ACCESS_LEVELS:
|
||||
return {"success": False, "message": "invalid access_level"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
# Ownership is the security boundary: dispatch by resource_type so
|
||||
# a mismatched type/id can't register a bogus grant.
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
# ``require`` dispatches by resource_type, so a mismatched
|
||||
# type/id can't register a bogus grant (the table has no FK).
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# A per-member share target must actually be a member of the team.
|
||||
if target_user_id and not TeamMembersRepository(conn).is_member(
|
||||
target_user_id, team_id
|
||||
@@ -487,8 +680,8 @@ class TeamGrants(Resource):
|
||||
grant = TeamResourceGrantsRepository(conn).grant(
|
||||
team_id,
|
||||
resource_type,
|
||||
resource_id,
|
||||
owner_id=user,
|
||||
ra.resource_id,
|
||||
owner_id=ra.owner_id,
|
||||
granted_by=user,
|
||||
access_level=access_level,
|
||||
target_user_id=target_user_id,
|
||||
@@ -512,15 +705,21 @@ class TeamGrants(Resource):
|
||||
logger.error("Share resource failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
@team_member_required
|
||||
def delete(self, team_id):
|
||||
"""Unshare a resource. Allowed for the resource owner or a team_admin.
|
||||
"""Unshare a resource from this team.
|
||||
|
||||
Identifiers come from query params (some proxies strip DELETE bodies),
|
||||
with a JSON-body fallback for older clients.
|
||||
Allowed with ``share`` on the resource — no membership needed, so an
|
||||
owner who left the team can still pull their resource back — or for a
|
||||
team_admin of this team. ``target_user_id`` picks one member's grant
|
||||
(absent → the whole-team grant). Identifiers come from query params
|
||||
(some proxies strip DELETE bodies), with a JSON-body fallback.
|
||||
"""
|
||||
user = _current_user()
|
||||
token = getattr(request, "decoded_token", None)
|
||||
if not user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}), 401
|
||||
)
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = request.args.get("resource_type") or data.get("resource_type")
|
||||
resource_id = request.args.get("resource_id") or data.get("resource_id")
|
||||
@@ -528,17 +727,15 @@ class TeamGrants(Resource):
|
||||
target_user_id = (
|
||||
request.args.get("target_user_id") or data.get("target_user_id") or ""
|
||||
).strip() or None
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
is_owner = owns_resource(conn, resource_type, resource_id, user)
|
||||
if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied:
|
||||
if not has_team_role(token, team_id, ROLE_TEAM_ADMIN):
|
||||
return {"success": False, "message": "Forbidden"}, 403
|
||||
revoked = TeamResourceGrantsRepository(conn).revoke(
|
||||
team_id, resource_type, resource_id, target_user_id=target_user_id
|
||||
)
|
||||
@@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource):
|
||||
@teams_ns.route("/resource_shares")
|
||||
class ResourceShares(Resource):
|
||||
def get(self):
|
||||
"""List the teams a resource the caller OWNS is shared with.
|
||||
"""List the teams a resource is shared with. Needs ``share`` on it.
|
||||
|
||||
Powers the share dialog (show current shares + unshare). Owner-only so a
|
||||
non-owner can't enumerate a resource's sharing graph.
|
||||
Powers the share dialog (current shares + unshare), so only someone who
|
||||
may change the sharing can enumerate it: 404 when the resource isn't
|
||||
visible, 403 when the caller's role can't share.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if (
|
||||
not is_valid_resource_type(resource_type)
|
||||
or not resource_id
|
||||
or not looks_like_uuid(resource_id)
|
||||
):
|
||||
if not _valid_resource(resource_type, resource_id):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
if not owns_resource(conn, resource_type, resource_id, user):
|
||||
return {"success": False, "message": "Not the resource owner"}, 403
|
||||
try:
|
||||
require(conn, resource_type, resource_id, user, "share")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
shares = TeamResourceGrantsRepository(conn).list_for_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
@@ -631,6 +827,83 @@ class ResourceShares(Resource):
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
def _settings_response(ra: ResourceAccess):
|
||||
"""The ``resource_settings`` body: switches plus the caller's access."""
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"resource_type": ra.resource_type,
|
||||
"resource_id": ra.resource_id,
|
||||
"settings": public_settings(ra.resource_type, ra.settings),
|
||||
**ra.payload(),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@teams_ns.route("/resource_settings")
|
||||
class ResourceSettings(Resource):
|
||||
def get(self):
|
||||
"""A resource's sharing switches. Anyone with access may read them.
|
||||
|
||||
Query: ``resource_type``, ``resource_id``. Returns ``settings`` as
|
||||
``[{key, value, default}]`` in display order, plus the caller's
|
||||
``access`` and ``allowed_actions``.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
resource_type = request.args.get("resource_type")
|
||||
resource_id = request.args.get("resource_id")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id:
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "use")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
return _settings_response(ra)
|
||||
except Exception as err:
|
||||
logger.error("Get resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
def put(self):
|
||||
"""Change a resource's sharing switches. Needs ``manage_settings`` (owner).
|
||||
|
||||
Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``.
|
||||
An unknown key or a non-boolean value is a 400. Returns the GET shape.
|
||||
"""
|
||||
user = _current_user()
|
||||
if not user:
|
||||
return {"success": False}, 401
|
||||
data = request.get_json(silent=True) or {}
|
||||
resource_type = data.get("resource_type")
|
||||
resource_id = data.get("resource_id")
|
||||
changes = data.get("settings")
|
||||
if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str):
|
||||
return {"success": False, "message": "invalid resource"}, 400
|
||||
if not isinstance(changes, dict):
|
||||
return {"success": False, "message": "settings must be an object"}, 400
|
||||
try:
|
||||
with db_session() as conn:
|
||||
try:
|
||||
ra = require(conn, resource_type, resource_id, user, "manage_settings")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
try:
|
||||
merged = set_settings(conn, resource_type, ra.resource_id, changes, user)
|
||||
except ValueError as bad:
|
||||
return {"success": False, "message": str(bad)}, 400
|
||||
updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged)
|
||||
return _settings_response(updated)
|
||||
except Exception as err:
|
||||
logger.error("Update resource settings failed: %s", err, exc_info=True)
|
||||
return {"success": False}, 400
|
||||
|
||||
|
||||
@teams_ns.route("/admin/teams")
|
||||
class AllTeams(Resource):
|
||||
method_decorators = []
|
||||
|
||||
+157
-90
@@ -7,7 +7,15 @@ from flask_restx import Namespace, Resource, fields
|
||||
|
||||
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.tools.routes import transform_actions
|
||||
from docsgpt.api.user.resource_access import AccessDenied, require
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.api.user.tools.routes import (
|
||||
_CREDENTIALS_FOR_NEW_SERVER,
|
||||
_MCP_CREDENTIAL_AUTH_TYPES,
|
||||
_mcp_host_changed,
|
||||
denied_response,
|
||||
transform_actions,
|
||||
)
|
||||
from docsgpt.cache import get_redis_instance
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -75,12 +83,60 @@ def _validate_mcp_server_url(config: dict) -> None:
|
||||
raise ValueError(f"Invalid server URL: {exc}") from exc
|
||||
|
||||
|
||||
_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
|
||||
|
||||
|
||||
def _existing_mcp_context(tool_id, user, config):
|
||||
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
|
||||
|
||||
With no ``tool_id`` the caller acts on their own new server. With one,
|
||||
the caller needs ``edit_credentials`` on that tool and everything runs as
|
||||
its owner. Stored secrets are write-only, so an empty secret field reuses
|
||||
the stored one while the host is unchanged; a new host never inherits them.
|
||||
|
||||
Returns:
|
||||
``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask
|
||||
response (404 / 400) to return as is.
|
||||
|
||||
Raises:
|
||||
AccessDenied: the caller can't see the tool (404) or can't change
|
||||
its credentials (403).
|
||||
"""
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
if not tool_id:
|
||||
return None, user, True, False, auth_credentials
|
||||
with db_readonly() as conn:
|
||||
ra = require(conn, "tool", tool_id, user, "edit_credentials")
|
||||
existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
if not existing_doc or existing_doc.get("name") != "mcp_tool":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found or access denied"}), 404,
|
||||
)
|
||||
existing_config = existing_doc.get("config") or {}
|
||||
moved = _mcp_host_changed(config, existing_config)
|
||||
auth_type = config.get("auth_type", "none")
|
||||
new_secret_keys = set(auth_credentials) - {"api_key_header"}
|
||||
if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400
|
||||
)
|
||||
credentials = dict(auth_credentials)
|
||||
existing_encrypted = None if moved else existing_config.get("encrypted_credentials")
|
||||
if existing_encrypted:
|
||||
credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **auth_credentials}
|
||||
return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials
|
||||
|
||||
|
||||
@tools_mcp_ns.route("/mcp_server/test")
|
||||
class TestMCPServerConfig(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"MCPServerTestModel",
|
||||
{
|
||||
"id": fields.String(
|
||||
required=False,
|
||||
description="Stored tool to test with (empty secrets reuse its stored ones)",
|
||||
),
|
||||
"config": fields.Raw(
|
||||
required=True, description="MCP server configuration to test"
|
||||
),
|
||||
@@ -111,11 +167,20 @@ class TestMCPServerConfig(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
_existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
|
||||
if not is_owner and config.get("auth_type") == "oauth":
|
||||
# An OAuth flow would store tokens under the editor's account
|
||||
# (and its popup event goes to that account), not the owner's.
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
|
||||
)
|
||||
test_config = config.copy()
|
||||
test_config["auth_credentials"] = auth_credentials
|
||||
|
||||
mcp_tool = MCPTool(config=test_config, user_id=user)
|
||||
mcp_tool = MCPTool(config=test_config, user_id=owner_id)
|
||||
result = mcp_tool.test_connection()
|
||||
|
||||
if result.get("requires_oauth"):
|
||||
@@ -148,6 +213,8 @@ class TestMCPServerConfig(Resource):
|
||||
"tools": result.get("tools", []),
|
||||
}
|
||||
return make_response(jsonify(safe_result), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server test request: {e}")
|
||||
return make_response(
|
||||
@@ -207,13 +274,55 @@ class MCPServerSave(Resource):
|
||||
|
||||
_validate_mcp_server_url(config)
|
||||
|
||||
auth_credentials = _extract_auth_credentials(config)
|
||||
# An existing id is always an update of THAT row, written as its
|
||||
# owner; it never falls through to creating a copy for the caller.
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
|
||||
existing_config = (existing_doc or {}).get("config") or {}
|
||||
auth_type = config.get("auth_type", "none")
|
||||
mcp_config = config.copy()
|
||||
mcp_config["auth_credentials"] = auth_credentials
|
||||
mcp_config["auth_credentials"] = merged_credentials
|
||||
keep_actions = False
|
||||
|
||||
if auth_type == "oauth":
|
||||
if not config.get("oauth_task_id"):
|
||||
if config.get("oauth_task_id"):
|
||||
if not is_owner:
|
||||
# The OAuth flow stores tokens under the account that
|
||||
# ran it; reconnecting as the owner is owner-only.
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"message": _ONLY_OWNER_RECONNECTS,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif (
|
||||
existing_doc is not None
|
||||
and not moved
|
||||
and existing_config.get("auth_type") == "oauth"
|
||||
):
|
||||
# Editing an already-connected server: keep its tools.
|
||||
actions_metadata = existing_doc.get("actions") or []
|
||||
keep_actions = True
|
||||
else:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
@@ -223,24 +332,8 @@ class MCPServerSave(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif auth_type == "none" or auth_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=user)
|
||||
elif auth_type == "none" or merged_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
|
||||
mcp_tool.discover_tools()
|
||||
actions_metadata = mcp_tool.get_actions_metadata()
|
||||
else:
|
||||
@@ -249,30 +342,10 @@ class MCPServerSave(Resource):
|
||||
)
|
||||
storage_config = config.copy()
|
||||
|
||||
tool_id = data.get("id")
|
||||
existing_doc = None
|
||||
existing_encrypted = None
|
||||
if tool_id:
|
||||
with db_readonly() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
existing_doc = repo.get_any(tool_id, user)
|
||||
if existing_doc and existing_doc.get("name") == "mcp_tool":
|
||||
existing_encrypted = (existing_doc.get("config") or {}).get(
|
||||
"encrypted_credentials"
|
||||
)
|
||||
else:
|
||||
existing_doc = None
|
||||
|
||||
if auth_credentials:
|
||||
if existing_encrypted:
|
||||
existing_secrets = decrypt_credentials(existing_encrypted, user)
|
||||
existing_secrets.update(auth_credentials)
|
||||
auth_credentials = existing_secrets
|
||||
if merged_credentials:
|
||||
storage_config["encrypted_credentials"] = encrypt_credentials(
|
||||
auth_credentials, user
|
||||
merged_credentials, owner_id
|
||||
)
|
||||
elif existing_encrypted:
|
||||
storage_config["encrypted_credentials"] = existing_encrypted
|
||||
|
||||
for field in [
|
||||
"api_key",
|
||||
@@ -283,58 +356,54 @@ class MCPServerSave(Resource):
|
||||
"redirect_uri",
|
||||
]:
|
||||
storage_config.pop(field, None)
|
||||
transformed_actions = transform_actions(actions_metadata)
|
||||
# Kept actions already carry the owner's on/off and approval flags.
|
||||
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
|
||||
|
||||
display_name = data["displayName"]
|
||||
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
|
||||
status_bool = bool(data.get("status", True))
|
||||
fields_out = {
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
}
|
||||
updated_message = (
|
||||
f"MCP server updated successfully! Discovered {len(transformed_actions)} tools."
|
||||
)
|
||||
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
if existing_doc:
|
||||
repo.update(
|
||||
str(existing_doc["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
if existing_doc is not None:
|
||||
# ``status`` is the owner's own chat switch; an editor's
|
||||
# save doesn't flip it.
|
||||
if is_owner:
|
||||
fields_out["status"] = status_bool
|
||||
repo.update(str(existing_doc["id"]), owner_id, fields_out)
|
||||
saved_id = str(existing_doc["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
fields_out["status"] = status_bool
|
||||
# Fall back to find_by_user_and_name — the original
|
||||
# dual-write path also ran an existence check before
|
||||
# deciding between insert and update.
|
||||
existing_by_name = repo.find_by_user_and_name(user, "mcp_tool")
|
||||
if tool_id is None and existing_by_name and (
|
||||
if existing_by_name and (
|
||||
(existing_by_name.get("config") or {}).get("server_url")
|
||||
== storage_config.get("server_url")
|
||||
):
|
||||
repo.update(
|
||||
str(existing_by_name["id"]), user,
|
||||
{
|
||||
"display_name": display_name,
|
||||
"custom_name": display_name,
|
||||
"description": description,
|
||||
"config": storage_config,
|
||||
"actions": transformed_actions,
|
||||
"status": status_bool,
|
||||
},
|
||||
)
|
||||
repo.update(str(existing_by_name["id"]), user, fields_out)
|
||||
saved_id = str(existing_by_name["id"])
|
||||
response_data = {
|
||||
"success": True,
|
||||
"id": saved_id,
|
||||
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"message": updated_message,
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
else:
|
||||
@@ -355,18 +424,9 @@ class MCPServerSave(Resource):
|
||||
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
|
||||
"tools_count": len(transformed_actions),
|
||||
}
|
||||
if tool_id and existing_doc is None:
|
||||
# Client requested update on a non-existent tool id.
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Tool not found or access denied",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
return make_response(jsonify(response_data), 200)
|
||||
except AccessDenied as e:
|
||||
return denied_response(e)
|
||||
except ValueError as e:
|
||||
current_app.logger.warning(f"Invalid MCP server save request: {e}")
|
||||
return make_response(
|
||||
@@ -455,6 +515,13 @@ class MCPAuthStatus(Resource):
|
||||
tools_repo = UserToolsRepository(conn)
|
||||
sessions_repo = ConnectorSessionsRepository(conn)
|
||||
all_tools = tools_repo.list_for_user(user)
|
||||
owned_ids = {str(t["id"]) for t in all_tools}
|
||||
# Team-shared MCP servers the caller can see run with the
|
||||
# owner's connection, so their status is the owner's.
|
||||
shared_ids = [
|
||||
tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids
|
||||
]
|
||||
all_tools = all_tools + tools_repo.list_by_ids(shared_ids)
|
||||
mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"]
|
||||
if not mcp_tools:
|
||||
return make_response(
|
||||
@@ -472,7 +539,7 @@ class MCPAuthStatus(Resource):
|
||||
if server_url:
|
||||
parsed = urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
oauth_server_urls[tool_id] = base_url
|
||||
oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url)
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
else:
|
||||
@@ -484,9 +551,9 @@ class MCPAuthStatus(Resource):
|
||||
# and the URL in ``server_url``; reuse the repo's
|
||||
# per-URL accessor rather than an ad-hoc $in query.
|
||||
url_has_tokens: dict = {}
|
||||
for base_url in set(oauth_server_urls.values()):
|
||||
for owner_id, base_url in set(oauth_server_urls.values()):
|
||||
session = sessions_repo.get_by_user_and_server_url(
|
||||
user, base_url,
|
||||
owner_id, base_url,
|
||||
)
|
||||
tokens = (
|
||||
(session or {}).get("session_data", {}) or {}
|
||||
@@ -494,13 +561,13 @@ class MCPAuthStatus(Resource):
|
||||
# MCP code also stashes tokens into token_info on
|
||||
# the row; consider either present as "connected".
|
||||
token_info = (session or {}).get("token_info") or {}
|
||||
url_has_tokens[base_url] = bool(
|
||||
url_has_tokens[(owner_id, base_url)] = bool(
|
||||
tokens.get("access_token")
|
||||
or token_info.get("access_token")
|
||||
)
|
||||
|
||||
for tool_id, base_url in oauth_server_urls.items():
|
||||
if url_has_tokens.get(base_url):
|
||||
for tool_id, key in oauth_server_urls.items():
|
||||
if url_has_tokens.get(key):
|
||||
statuses[tool_id] = "connected"
|
||||
else:
|
||||
statuses[tool_id] = "needs_auth"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Tool management routes."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.agents.default_tools import (
|
||||
builtin_agent_tools_for_management,
|
||||
@@ -13,12 +18,21 @@ from docsgpt.agents.default_tools import (
|
||||
is_synthesized_tool_id,
|
||||
WORKFLOW_ONLY_BUILTINS,
|
||||
)
|
||||
from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY
|
||||
from docsgpt.agents.tools.spec_parser import parse_spec
|
||||
from docsgpt.agents.tools.tool_manager import ToolManager
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.pat.rules import filter_listing
|
||||
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
delete_settings,
|
||||
payload_for,
|
||||
require,
|
||||
ResourceAccess,
|
||||
settings_many,
|
||||
)
|
||||
from docsgpt.api.user.team_sharing import visible_with_access
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
|
||||
@@ -26,6 +40,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository
|
||||
from docsgpt.storage.db.repositories.notes import NotesRepository
|
||||
from docsgpt.storage.db.repositories.todos import TodosRepository
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from docsgpt.storage.db.repositories.users import UsersRepository
|
||||
from docsgpt.storage.db.session import db_readonly, db_session
|
||||
@@ -166,6 +183,238 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u
|
||||
return storage_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access + secrets helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server"
|
||||
_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that"
|
||||
_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"}
|
||||
_META_KEYS = ("name", "displayName", "customName", "description", "actions")
|
||||
|
||||
|
||||
class CredentialsRequired(Exception):
|
||||
"""A save moved a tool to a new host without supplying new secrets."""
|
||||
|
||||
|
||||
def denied_response(err: AccessDenied):
|
||||
"""JSON response for an :class:`AccessDenied` (403 or 404)."""
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def check_action(ra: ResourceAccess, action: str) -> None:
|
||||
"""Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``."""
|
||||
if not ra.can(action):
|
||||
raise AccessDenied(403, _FORBIDDEN_MESSAGE)
|
||||
|
||||
|
||||
def url_host(url: Any) -> str:
|
||||
"""Lower-cased host of ``url`` ('' when it has none)."""
|
||||
try:
|
||||
return (urlparse(str(url or "").strip()).hostname or "").lower()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _has_value(value: Any) -> bool:
|
||||
return value is not None and value != ""
|
||||
|
||||
|
||||
def _secret_props(action: Any):
|
||||
"""Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params."""
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
for section in API_TOOL_SECRET_SECTIONS:
|
||||
block = action.get(section)
|
||||
props = block.get("properties") if isinstance(block, dict) else None
|
||||
if not isinstance(props, dict):
|
||||
continue
|
||||
for param, spec in props.items():
|
||||
if isinstance(spec, dict):
|
||||
yield section, param, spec
|
||||
|
||||
|
||||
def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict:
|
||||
"""Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values."""
|
||||
config = config or {}
|
||||
blob = config.get(API_TOOL_SECRETS_KEY)
|
||||
secrets: dict = decrypt_credentials(blob, owner_id) if blob else {}
|
||||
for name, action in (config.get("actions") or {}).items():
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value)
|
||||
return secrets
|
||||
|
||||
|
||||
def mask_api_tool_config(config: dict) -> dict:
|
||||
"""Copy of an api_tool config with header/query values blanked and ``has_value`` set.
|
||||
|
||||
Args:
|
||||
config: The stored ``user_tools.config``.
|
||||
|
||||
Returns:
|
||||
A deep copy safe to return to any caller: the encrypted blob is dropped
|
||||
and every header / query-param entry has ``value: ""`` plus ``has_value``.
|
||||
"""
|
||||
out = copy.deepcopy(config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
for action in (out.get("actions") or {}).values():
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value"))
|
||||
spec["value"] = ""
|
||||
return out
|
||||
|
||||
|
||||
def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict:
|
||||
"""Move api_tool header/query values into an encrypted blob keyed by the owner.
|
||||
|
||||
An incoming entry with a value replaces the stored one; an empty value with
|
||||
``has_value`` keeps it (legacy plaintext values included); anything else
|
||||
clears it. When an action's URL host changes the stored values are not
|
||||
carried over.
|
||||
|
||||
Args:
|
||||
new_config: The config the client sent.
|
||||
existing_config: The stored config (``{}`` on create).
|
||||
owner_id: The tool row's ``user_id`` — the encryption key owner.
|
||||
|
||||
Returns:
|
||||
The config to persist.
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: a host changed, the client asked to keep a value,
|
||||
and there is nothing to keep.
|
||||
"""
|
||||
existing_config = existing_config or {}
|
||||
stored = _stored_api_tool_secrets(existing_config, owner_id)
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
out = copy.deepcopy(new_config or {})
|
||||
out.pop(API_TOOL_SECRETS_KEY, None)
|
||||
sealed: dict = {}
|
||||
for name, action in (out.get("actions") or {}).items():
|
||||
old = old_actions.get(name) if isinstance(old_actions, dict) else None
|
||||
moved = isinstance(old, dict) and url_host(old.get("url")) != url_host(
|
||||
action.get("url") if isinstance(action, dict) else ""
|
||||
)
|
||||
prior = {} if moved else stored.get(name, {})
|
||||
for section, param, spec in _secret_props(action):
|
||||
value = spec.get("value")
|
||||
if _has_value(value):
|
||||
kept = value
|
||||
elif spec.get("has_value"):
|
||||
kept = (prior.get(section) or {}).get(param)
|
||||
if not _has_value(kept):
|
||||
if moved:
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
kept = None
|
||||
else:
|
||||
kept = None
|
||||
spec["value"] = ""
|
||||
spec["has_value"] = kept is not None
|
||||
if kept is not None:
|
||||
sealed.setdefault(name, {}).setdefault(section, {})[param] = kept
|
||||
if sealed:
|
||||
out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id)
|
||||
return out
|
||||
|
||||
|
||||
def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool:
|
||||
"""Whether an api_tool config change touches endpoints or secrets.
|
||||
|
||||
Descriptions, parameter schemas and on/off flags are ``edit``; a new or
|
||||
changed URL, a new action (it brings a URL), a secret value or any other
|
||||
config key is ``edit_credentials``.
|
||||
"""
|
||||
new_config = new_config or {}
|
||||
existing_config = existing_config or {}
|
||||
ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials")
|
||||
if {k: v for k, v in new_config.items() if k not in ignore} != {
|
||||
k: v for k, v in existing_config.items() if k not in ignore
|
||||
}:
|
||||
return True
|
||||
old_actions = existing_config.get("actions") or {}
|
||||
for name, action in (new_config.get("actions") or {}).items():
|
||||
old = old_actions.get(name)
|
||||
if not isinstance(old, dict) or not isinstance(action, dict):
|
||||
return True
|
||||
if str(action.get("url") or "") != str(old.get("url") or ""):
|
||||
return True
|
||||
for _section, _param, spec in _secret_props(action):
|
||||
if _has_value(spec.get("value")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
|
||||
old_url = (existing_config or {}).get("server_url")
|
||||
return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url"))
|
||||
|
||||
|
||||
def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict:
|
||||
"""Validate-free merge of an incoming config with the stored one, as the owner.
|
||||
|
||||
Handles the three secret stores: ``config_requirements`` secrets
|
||||
(``encrypted_credentials``), api_tool header/query values, and the MCP
|
||||
host-change rule (a new server host drops stored credentials).
|
||||
|
||||
Raises:
|
||||
CredentialsRequired: the MCP host changed and no new secret arrived.
|
||||
"""
|
||||
owner_id = tool_doc["user_id"]
|
||||
existing_config = tool_doc.get("config") or {}
|
||||
if tool_doc.get("name") == "api_tool":
|
||||
return _seal_api_tool_secrets(new_config, existing_config, owner_id)
|
||||
moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config)
|
||||
if moved:
|
||||
existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"}
|
||||
final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id)
|
||||
if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get(
|
||||
"encrypted_credentials"
|
||||
):
|
||||
raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER)
|
||||
return final
|
||||
|
||||
|
||||
def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict:
|
||||
"""``tool_id -> team name`` through which a grant reaches ``user_id``."""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name
|
||||
FROM team_resource_grants g
|
||||
JOIN team_members m ON m.team_id = g.team_id
|
||||
JOIN teams t ON t.id = g.team_id
|
||||
WHERE m.user_id = :user_id AND g.resource_type = 'tool'
|
||||
AND g.resource_id = ANY(CAST(:ids AS uuid[]))
|
||||
AND (g.target_user_id IS NULL OR g.target_user_id = :user_id)
|
||||
ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
return {str(r[0]): r[1] for r in rows}
|
||||
|
||||
|
||||
def _owner_labels(conn: Connection, owner_ids) -> dict:
|
||||
"""``user_id -> email`` for the owners that have one on record."""
|
||||
ids = sorted({str(o) for o in owner_ids if o})
|
||||
if not ids:
|
||||
return {}
|
||||
rows = conn.execute(
|
||||
text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"),
|
||||
{"ids": ids},
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]:
|
||||
"""The tool row behind ``ra``, read as its owner."""
|
||||
return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id)
|
||||
|
||||
|
||||
def transform_actions(actions_metadata):
|
||||
"""Set default flags on action metadata for storage.
|
||||
|
||||
@@ -239,6 +488,10 @@ class GetTools(Resource):
|
||||
team_shared = visible_with_access(conn, user, "tool")
|
||||
shared_ids = [tid for tid in team_shared if tid not in owned_ids]
|
||||
shared_rows = tools_repo.list_by_ids(shared_ids)
|
||||
switches = settings_many(conn, "tool", [*owned_ids, *shared_ids])
|
||||
prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids)
|
||||
shared_via = _shared_via(conn, user, shared_ids)
|
||||
owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows])
|
||||
user_tools = []
|
||||
|
||||
def _shape_tool(row, *, ownership="user", force_strip_secret=False):
|
||||
@@ -257,14 +510,25 @@ class GetTools(Resource):
|
||||
):
|
||||
tool_copy["config"]["has_encrypted_credentials"] = True
|
||||
tool_copy["config"].pop("encrypted_credentials", None)
|
||||
if tool_copy.get("name") == "api_tool":
|
||||
# Header / query-param values are secrets for everyone.
|
||||
tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {})
|
||||
tool_copy["ownership"] = ownership
|
||||
return tool_copy
|
||||
|
||||
for row in rows:
|
||||
user_tools.append(_shape_tool(row))
|
||||
shaped = _shape_tool(row)
|
||||
shaped.update(payload_for("tool", "owner", switches.get(str(row["id"]))))
|
||||
shaped["in_chat"] = bool(row.get("status"))
|
||||
user_tools.append(shaped)
|
||||
for row in shared_rows:
|
||||
tid = str(row["id"])
|
||||
shaped = _shape_tool(row, ownership="team", force_strip_secret=True)
|
||||
shaped["team_access"] = team_shared.get(str(row["id"]))
|
||||
shaped["team_access"] = team_shared.get(tid)
|
||||
shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid)))
|
||||
shaped["in_chat"] = prefs.get(tid, False)
|
||||
shaped["shared_via"] = shared_via.get(tid)
|
||||
shaped["owner_label"] = owner_labels.get(row.get("user_id"))
|
||||
user_tools.append(shaped)
|
||||
|
||||
# ``scheduler`` is dual-registered (default chat tool + agent-
|
||||
@@ -275,6 +539,7 @@ class GetTools(Resource):
|
||||
for default_row in default_tools_for_management(user_doc):
|
||||
default_copy = _row_to_api(default_row)
|
||||
default_copy["default"] = True
|
||||
default_copy["in_chat"] = bool(default_copy.get("status"))
|
||||
if default_copy.get("name") in BUILTIN_AGENT_TOOLS:
|
||||
default_copy["builtin"] = True
|
||||
seen_ids.add(str(default_copy["id"]))
|
||||
@@ -386,9 +651,12 @@ class CreateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
if data["name"] == "api_tool":
|
||||
storage_config = _seal_api_tool_secrets(data["config"], {}, user)
|
||||
else:
|
||||
storage_config = _encrypt_secret_fields(
|
||||
data["config"], config_requirements, user
|
||||
)
|
||||
with db_session() as conn:
|
||||
created = UserToolsRepository(conn).create(
|
||||
user,
|
||||
@@ -478,43 +746,47 @@ class UpdateTool(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]:
|
||||
for action_name in list((data["config"]["actions"] or {}).keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
try:
|
||||
update_data: dict = {}
|
||||
for key in ("name", "displayName", "customName", "description", "actions"):
|
||||
for key in _META_KEYS:
|
||||
if key in data:
|
||||
update_data[key] = data[key]
|
||||
if "config" in data:
|
||||
if "actions" in data["config"]:
|
||||
for action_name in list(data["config"]["actions"].keys()):
|
||||
if not validate_function_name(action_name):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
|
||||
"param": "tools[].function.name",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
with db_session() as conn:
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404,
|
||||
)
|
||||
if update_data:
|
||||
check_action(ra, "edit")
|
||||
if "config" in data:
|
||||
tool_name = tool_doc.get("name", data.get("name"))
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
if tool_name == "api_tool" and not _api_tool_config_needs_credentials(
|
||||
data["config"], existing_config
|
||||
):
|
||||
check_action(ra, "edit")
|
||||
else:
|
||||
check_action(ra, "edit_credentials")
|
||||
tool_instance = tool_manager.tools.get(tool_name)
|
||||
config_requirements = (
|
||||
tool_instance.get_config_requirements()
|
||||
if tool_instance
|
||||
else {}
|
||||
tool_instance.get_config_requirements() if tool_instance else {}
|
||||
)
|
||||
existing_config = tool_doc.get("config", {}) or {}
|
||||
has_existing_secrets = "encrypted_credentials" in existing_config
|
||||
|
||||
if config_requirements:
|
||||
validation_errors = _validate_config(
|
||||
data["config"], config_requirements,
|
||||
@@ -529,29 +801,27 @@ class UpdateTool(Resource):
|
||||
}),
|
||||
400,
|
||||
)
|
||||
|
||||
update_data["config"] = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
update_data["config"] = _prepare_tool_config(
|
||||
tool_doc, data["config"], config_requirements
|
||||
)
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
)
|
||||
else:
|
||||
if "status" in data:
|
||||
update_data["status"] = bool(data["status"])
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
if ra.access == "owner":
|
||||
update_data["status"] = bool(data["status"])
|
||||
else:
|
||||
# A grantee's chat switch is personal; the owner's
|
||||
# ``status`` is the owner's own chat setting.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, str(tool_doc["id"]), bool(data["status"])
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, _api_to_update_fields(update_data),
|
||||
if update_data:
|
||||
UserToolsRepository(conn).update(
|
||||
str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data),
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
@@ -596,7 +866,8 @@ class UpdateToolConfig(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
ra = require(conn, "tool", data["id"], user, "edit_credentials")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(jsonify({"success": False}), 404)
|
||||
|
||||
@@ -633,11 +904,13 @@ class UpdateToolConfig(Resource):
|
||||
400,
|
||||
)
|
||||
|
||||
final_config = _merge_secrets_on_update(
|
||||
data["config"], existing_config, config_requirements, user
|
||||
)
|
||||
final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements)
|
||||
|
||||
repo.update(str(tool_doc["id"]), user, {"config": final_config})
|
||||
repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except CredentialsRequired as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool config: {err}", exc_info=True
|
||||
@@ -684,20 +957,12 @@ class UpdateToolActions(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if tool_doc:
|
||||
repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]})
|
||||
else:
|
||||
# Team editor write path (secrets stay owner-only — actions
|
||||
# carry no credentials, so editing them is safe).
|
||||
owner = effective_write_owner(conn, "tool", data["id"], user)
|
||||
if not owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
repo.update(data["id"], owner, {"actions": data["actions"]})
|
||||
# ``edit`` covers action on/off, descriptions and approval
|
||||
# (``require_approval``); actions carry no credentials.
|
||||
ra = require(conn, "tool", data["id"], user, "edit")
|
||||
UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["actions"]})
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool actions: {err}", exc_info=True
|
||||
@@ -753,16 +1018,19 @@ class UpdateToolStatus(Resource):
|
||||
400,
|
||||
)
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
ra = require(conn, "tool", data["id"], user, "use")
|
||||
if ra.access == "owner":
|
||||
UserToolsRepository(conn).update(
|
||||
ra.resource_id, ra.owner_id, {"status": bool(data["status"])},
|
||||
)
|
||||
repo.update(
|
||||
str(tool_doc["id"]), user, {"status": bool(data["status"])},
|
||||
)
|
||||
else:
|
||||
# A grantee's "In my chats" switch is personal.
|
||||
check_action(ra, "use_in_own")
|
||||
UserToolPreferencesRepository(conn).set_in_chat(
|
||||
user, ra.resource_id, bool(data["status"])
|
||||
)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error updating tool status: {err}", exc_info=True
|
||||
@@ -802,13 +1070,13 @@ class DeleteTool(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404
|
||||
)
|
||||
repo.delete(str(tool_doc["id"]), user)
|
||||
ra = require(conn, "tool", data["id"], user, "delete")
|
||||
# Grants are removed by the ``user_tools`` delete trigger and
|
||||
# chat preferences by FK cascade; the switches have no FK.
|
||||
UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id)
|
||||
delete_settings(conn, "tool", ra.resource_id)
|
||||
except AccessDenied as err:
|
||||
return denied_response(err)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from flask import current_app, request
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from docsgpt.agents.workflows.cel_evaluator import (
|
||||
CelEvaluationError,
|
||||
validate_cel_expression,
|
||||
)
|
||||
from docsgpt.api.user.resource_access import AccessDenied, resolve
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
|
||||
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
|
||||
@@ -46,6 +48,60 @@ def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str)
|
||||
return repo.get_by_legacy_id(workflow_id, user_id)
|
||||
|
||||
|
||||
def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
|
||||
"""Resolve a workflow the caller may ``action``, and the id to act as.
|
||||
|
||||
The caller's own workflow is always theirs. Otherwise access comes from
|
||||
an agent of the workflow's owner that uses it: ``view`` to read it,
|
||||
``edit`` to change it, ``delete`` to remove it (checked on that agent).
|
||||
|
||||
Args:
|
||||
conn: Open database connection.
|
||||
workflow_id: Workflow UUID or legacy id.
|
||||
user_id: The caller.
|
||||
action: Agent action required (``view``, ``edit`` or ``delete``).
|
||||
|
||||
Returns:
|
||||
``(workflow, acting_user_id)``.
|
||||
|
||||
Raises:
|
||||
AccessDenied: 404 when not visible, 403 when the role can't ``action``.
|
||||
"""
|
||||
repo = WorkflowsRepository(conn)
|
||||
own = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if own is not None:
|
||||
return own, user_id
|
||||
if not looks_like_uuid(str(workflow_id)):
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
workflow = repo.get_by_id(str(workflow_id))
|
||||
if workflow is None:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
agent_ids = conn.execute(
|
||||
sql_text(
|
||||
"SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"
|
||||
),
|
||||
{"wid": str(workflow["id"]), "owner": workflow["user_id"]},
|
||||
).scalars().all()
|
||||
visible = False
|
||||
for agent_id in agent_ids:
|
||||
ra = resolve(conn, "agent", str(agent_id), user_id)
|
||||
if ra is None:
|
||||
continue
|
||||
visible = True
|
||||
if ra.can(action):
|
||||
return workflow, ra.owner_id
|
||||
if not visible:
|
||||
raise AccessDenied(404, "Workflow not found")
|
||||
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
||||
|
||||
|
||||
def _denied(err: AccessDenied):
|
||||
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
|
||||
return make_response(
|
||||
jsonify({"success": False, "error": err.message, "message": err.message}), err.status
|
||||
)
|
||||
|
||||
|
||||
def _write_graph(
|
||||
conn,
|
||||
pg_workflow_id: str,
|
||||
@@ -499,10 +555,10 @@ class WorkflowDetail(Resource):
|
||||
user_id = get_user_id()
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
graph_version = get_workflow_graph_version(workflow)
|
||||
nodes = WorkflowNodesRepository(conn).find_by_version(
|
||||
@@ -533,21 +589,23 @@ class WorkflowDetail(Resource):
|
||||
nodes_data = data.get("nodes", [])
|
||||
edges_data = data.get("edges", [])
|
||||
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=user_id
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# Validated as the owner: the workflow runs with the owner's
|
||||
# models, so their BYOM ids are the ones that must resolve.
|
||||
validation_errors = validate_workflow_structure(
|
||||
nodes_data, edges_data, user_id=acting
|
||||
)
|
||||
if validation_errors:
|
||||
return error_response(
|
||||
"Workflow validation failed", errors=validation_errors
|
||||
)
|
||||
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
||||
pg_workflow_id = str(workflow["id"])
|
||||
current_graph_version = get_workflow_graph_version(workflow)
|
||||
next_graph_version = current_graph_version + 1
|
||||
@@ -557,7 +615,7 @@ class WorkflowDetail(Resource):
|
||||
nodes_data, edges_data,
|
||||
)
|
||||
repo.update(
|
||||
pg_workflow_id, user_id,
|
||||
pg_workflow_id, acting,
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
@@ -582,11 +640,12 @@ class WorkflowDetail(Resource):
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = WorkflowsRepository(conn)
|
||||
workflow = _resolve_workflow(repo, workflow_id, user_id)
|
||||
if workflow is None:
|
||||
return error_response("Workflow not found", 404)
|
||||
try:
|
||||
workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete")
|
||||
except AccessDenied as denied:
|
||||
return _denied(denied)
|
||||
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
|
||||
repo.delete(str(workflow["id"]), user_id)
|
||||
repo.delete(str(workflow["id"]), acting)
|
||||
except Exception as err:
|
||||
return _workflow_error_response("Failed to delete workflow", err)
|
||||
|
||||
|
||||
@@ -187,6 +187,23 @@ Index(
|
||||
team_resource_grants_table.c.resource_id,
|
||||
)
|
||||
|
||||
# Per-asset sharing switches set by the owner (migration 0038). A missing row
|
||||
# means every switch is at its default; keys are validated in
|
||||
# ``docsgpt/api/user/resource_access.py``.
|
||||
resource_share_settings_table = Table(
|
||||
"resource_share_settings",
|
||||
metadata,
|
||||
Column("resource_type", Text, primary_key=True),
|
||||
Column("resource_id", UUID(as_uuid=True), primary_key=True),
|
||||
Column("settings", JSONB, nullable=False, server_default="{}"),
|
||||
Column("updated_by", Text),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
CheckConstraint(
|
||||
"resource_type IN ('agent', 'source', 'prompt', 'tool')",
|
||||
name="resource_share_settings_type_check",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
prompts_table = Table(
|
||||
"prompts",
|
||||
@@ -218,6 +235,22 @@ user_tools_table = Table(
|
||||
Column("legacy_mongo_id", Text),
|
||||
)
|
||||
|
||||
# A grantee's personal "In my chats" switch for a tool shared with them
|
||||
# (migration 0038). The owner's own switch stays ``user_tools.status``.
|
||||
user_tool_preferences_table = Table(
|
||||
"user_tool_preferences",
|
||||
metadata,
|
||||
Column("user_id", Text, primary_key=True),
|
||||
Column(
|
||||
"tool_id",
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user_tools.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
Column("in_chat", Boolean, nullable=False, server_default="false"),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
token_usage_table = Table(
|
||||
"token_usage",
|
||||
metadata,
|
||||
|
||||
@@ -107,8 +107,9 @@ class TeamResourceGrantsRepository:
|
||||
that one member (the caller must have validated they're a team member).
|
||||
``ON CONFLICT`` on the functional dedup index makes re-sharing
|
||||
last-write-wins on ``access_level``. The caller MUST have verified
|
||||
``granted_by`` owns the resource (dispatched by ``resource_type``) — the
|
||||
polymorphic table has no FK to catch a type/id mismatch.
|
||||
``granted_by`` holds ``share`` on the resource (``resource_access.require``,
|
||||
dispatched by ``resource_type``) and pass the real owner as ``owner_id`` —
|
||||
the polymorphic table has no FK to catch a type/id mismatch.
|
||||
"""
|
||||
result = self._conn.execute(
|
||||
text(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Repository for the ``user_tool_preferences`` table.
|
||||
|
||||
A grantee's personal "In my chats" switch for a tool shared with them. The
|
||||
owner's own switch stays in ``user_tools.status``; a missing row here means
|
||||
off, so sharing a tool never adds it to anyone's chats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
|
||||
|
||||
class UserToolPreferencesRepository:
|
||||
"""Per-user, per-tool chat preferences for shared tools."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def set_in_chat(self, user_id: str, tool_id: str, in_chat: bool) -> None:
|
||||
"""Upsert the caller's "In my chats" switch for one tool.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_id: The shared tool's UUID.
|
||||
in_chat: Whether the tool joins the grantee's agentless chats.
|
||||
"""
|
||||
self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_tool_preferences (user_id, tool_id, in_chat)
|
||||
VALUES (:user_id, CAST(:tool_id AS uuid), :in_chat)
|
||||
ON CONFLICT (user_id, tool_id)
|
||||
DO UPDATE SET in_chat = EXCLUDED.in_chat, updated_at = now()
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "tool_id": str(tool_id), "in_chat": bool(in_chat)},
|
||||
)
|
||||
|
||||
def in_chat_many(self, user_id: str, tool_ids: Iterable[str]) -> dict[str, bool]:
|
||||
"""``tool_id -> in_chat`` for the given tools; missing rows are False.
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
tool_ids: Tool ids to look up (non-UUIDs are ignored).
|
||||
|
||||
Returns:
|
||||
A dict with one entry per UUID-shaped input id.
|
||||
"""
|
||||
ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))]
|
||||
out = {tid: False for tid in ids}
|
||||
if not ids or not user_id:
|
||||
return out
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id, in_chat FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND tool_id = ANY(CAST(:ids AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id, "ids": ids},
|
||||
).fetchall()
|
||||
for tool_id, in_chat in rows:
|
||||
out[str(tool_id)] = bool(in_chat)
|
||||
return out
|
||||
|
||||
def list_in_chat_tool_ids(self, user_id: str) -> list[str]:
|
||||
"""Ids of tools the user switched into their chats (any owner).
|
||||
|
||||
Args:
|
||||
user_id: The grantee's user id.
|
||||
|
||||
Returns:
|
||||
Tool ids as strings; access must still be re-checked by the caller.
|
||||
"""
|
||||
if not user_id:
|
||||
return []
|
||||
rows = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT tool_id FROM user_tool_preferences
|
||||
WHERE user_id = :user_id AND in_chat = true
|
||||
ORDER BY updated_at
|
||||
"""
|
||||
),
|
||||
{"user_id": user_id},
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows]
|
||||
+4
-1
@@ -890,7 +890,10 @@ line, a trailing control) is `ListRow` inside `ListRows` (`divide-y
|
||||
divide-border`, no box of its own; wrap it in `Card padding="none"` or a
|
||||
bordered list for one). Rows are `px-4 py-3`, the title `text-sm
|
||||
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
|
||||
hovers to `bg-accent` and draws an inset focus ring. An icon square in
|
||||
hovers to `bg-accent` and draws an inset focus ring. `selected` marks the
|
||||
row whose detail is open in a drawer beside the list (a team's shared
|
||||
resources): the `bg-secondary` tint of a selected TableRow, kept on hover,
|
||||
with `aria-current`. An icon square in
|
||||
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
|
||||
In a narrow side panel (the graph node panel's relationships) rows are
|
||||
`size="sm"`: `px-2 py-1.5`, `gap-2.5`, `rounded-md` and top-aligned so a small
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
agentEditPathFor,
|
||||
sharedAgentPath,
|
||||
} from './agents/paths';
|
||||
import { canOpenAgentEditor } from './agents/agentAccess';
|
||||
import { Agent } from './agents/types';
|
||||
import conversationService from './api/services/conversationService';
|
||||
import userService from './api/services/userService';
|
||||
@@ -391,9 +392,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
const currentConversation = conversationId
|
||||
? conversations?.data?.find((c) => c.id === conversationId)
|
||||
: undefined;
|
||||
const ownsSelectedAgent = Boolean(
|
||||
selectedAgent?.id && agents?.some((a) => a.id === selectedAgent.id),
|
||||
);
|
||||
// Edit agent is offered to a role that may open the edit page.
|
||||
const canEditSelectedAgent = canOpenAgentEditor(selectedAgent, agents);
|
||||
const mobileTitle = routeSection
|
||||
? undefined
|
||||
: (currentConversation?.name ?? selectedAgent?.name);
|
||||
@@ -863,7 +863,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
onRename={updateConversationName}
|
||||
onDelete={handleDeleteConversation}
|
||||
editAgentPath={
|
||||
!routeSection && ownsSelectedAgent && selectedAgent
|
||||
!routeSection && canEditSelectedAgent && selectedAgent
|
||||
? agentEditPathFor(selectedAgent)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
goToLevel: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ preference: { token: null, agents: [] } }),
|
||||
useDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { deleteAgent: mocks.deleteAgent },
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/SidebarLevelProvider', () => ({
|
||||
useSidebarLevel: () => ({ goToLevel: mocks.goToLevel }),
|
||||
}));
|
||||
|
||||
vi.mock('../modals/MoveToFolderModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import AgentCard from './AgentCard';
|
||||
import type { Agent } from './types';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER_ACTIONS = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR_ACTIONS = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const VIEWER_ACTIONS = ['pin', 'use'];
|
||||
|
||||
const agentWith = (
|
||||
access: 'owner' | 'editor' | 'viewer',
|
||||
allowed: string[],
|
||||
): Agent =>
|
||||
({
|
||||
id: 'a1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}) as Agent;
|
||||
|
||||
describe('AgentCard menu', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.deleteAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (agent: Agent, section: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentCard agent={agent} agents={[agent]} section={section} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.card.actions"]',
|
||||
);
|
||||
if (!trigger) return [];
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
);
|
||||
};
|
||||
|
||||
const menuLabels = async () =>
|
||||
(await openMenu()).map((item) => item.textContent);
|
||||
|
||||
it('gives the owner the full menu', async () => {
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.folders.moveToFolder',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an editor Logs, Edit, Export and Pin', async () => {
|
||||
await render(agentWith('editor', EDITOR_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('brings Share and Delete back for an editor the owner allows', async () => {
|
||||
await render(
|
||||
agentWith('editor', [...EDITOR_ACTIONS, 'share', 'delete']),
|
||||
'team',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a viewer Pin only', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual(['agents.card.pin']);
|
||||
});
|
||||
|
||||
it('adds Logs for a viewer when the owner shares logs', async () => {
|
||||
await render(agentWith('viewer', [...VIEWER_ACTIONS, 'view_logs']), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows no menu to a viewer of a draft', async () => {
|
||||
await render(
|
||||
{ ...agentWith('viewer', VIEWER_ACTIONS), status: 'draft' },
|
||||
'team',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="agents.card.actions"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an own agent without access fields as the owner', async () => {
|
||||
const own = {
|
||||
...agentWith('owner', []),
|
||||
access: undefined,
|
||||
allowed_actions: undefined,
|
||||
} as Agent;
|
||||
await render(own, 'user');
|
||||
expect(await menuLabels()).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('hides Logs on an own draft (no runs to show) but keeps the rest', async () => {
|
||||
await render(
|
||||
{ ...agentWith('owner', OWNER_ACTIONS), status: 'draft' },
|
||||
'user',
|
||||
);
|
||||
const labels = await menuLabels();
|
||||
expect(labels).not.toContain('agents.form.buttons.logs');
|
||||
expect(labels).not.toContain('agents.card.pin');
|
||||
expect(labels).toContain('agents.edit');
|
||||
});
|
||||
|
||||
it('gives Discovered cards Pin and Remove; the card itself opens the agent', async () => {
|
||||
await render(
|
||||
{
|
||||
...agentWith('viewer', VIEWER_ACTIONS),
|
||||
shared_token: 'tok',
|
||||
},
|
||||
'shared',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.card.pin',
|
||||
'agents.card.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the chat when a viewer clicks a published card', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[role="button"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'preference/setSelectedAgent' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a refused delete in a toast', async () => {
|
||||
mocks.deleteAgent.mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Only the owner can delete' }),
|
||||
});
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
const items = await openMenu();
|
||||
await act(async () =>
|
||||
items
|
||||
.find((i) => i.textContent === 'agents.form.buttons.delete')!
|
||||
.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[data-testid="confirm"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Activity,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -25,6 +24,7 @@ import { Modal } from '../components/ui/modal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MoveToFolderModal from '../modals/MoveToFolderModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { useSidebarLevel } from '../navigation/SidebarLevelProvider';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import {
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
agentLogsPath,
|
||||
sharedAgentPath,
|
||||
} from './paths';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { Agent } from './types';
|
||||
|
||||
type AgentCardProps = {
|
||||
@@ -84,6 +86,59 @@ export default function AgentCard({
|
||||
onClick: () => togglePin(),
|
||||
};
|
||||
|
||||
const ownedMenu: MenuOption[] = [
|
||||
...(canAgent(agent, 'view_logs')
|
||||
? [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'view')
|
||||
? [{ icon: Pencil, label: t('agents.edit'), onClick: openEditor }]
|
||||
: []),
|
||||
...(can(agent, 'export')
|
||||
? [
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'share')
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canAgent(agent, 'pin') ? [pinOption] : []),
|
||||
...(can(agent, 'move_folder')
|
||||
? [
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'delete')
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const menuOptionsConfig: Record<string, MenuOption[]> = {
|
||||
template: [
|
||||
{
|
||||
@@ -92,64 +147,14 @@ export default function AgentCard({
|
||||
onClick: () => handleDuplicate(),
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
// Sharing is an owner-only action: only show it for agents the user
|
||||
// owns ('user'), not agents shared into their workspace by a team.
|
||||
...(agent.ownership === 'user'
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
],
|
||||
// Agents shared with the user via a team. They don't own it, so only
|
||||
// non-destructive, non-owner actions are offered: open the config
|
||||
// (editors can save, viewers see it read-only) and pin for quick access.
|
||||
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
|
||||
team: [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
],
|
||||
// My agents and the Team section share one menu, built from what the
|
||||
// caller's role allows on this agent (`allowed_actions` from the API).
|
||||
// Editors get Logs, Edit, Export and Pin; viewers only Pin. Share, Move
|
||||
// and Delete stay with the owner unless the owner's switches widen them.
|
||||
user: ownedMenu,
|
||||
team: ownedMenu,
|
||||
// Discovered (link-opened) agents: the card itself opens the agent.
|
||||
shared: [
|
||||
{
|
||||
icon: ExternalLink,
|
||||
label: t('agents.card.open'),
|
||||
onClick: () => navigate(sharedAgentPath(agent.shared_token)),
|
||||
},
|
||||
pinOption,
|
||||
{
|
||||
icon: Trash2,
|
||||
@@ -244,13 +249,31 @@ export default function AgentCard({
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const response = await userService.deleteAgent(agent.id ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.then((data: { message?: string }) => data?.message)
|
||||
.catch(() => null);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: message || t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedAgents = agents.filter(
|
||||
(prevAgent) => prevAgent.id !== agent.id,
|
||||
);
|
||||
updateAgents?.(updatedAgents);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,12 +323,14 @@ export default function AgentCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
{menuOptions.length > 0 && (
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
)}
|
||||
{/* Team access badge — pinned to the top row, left of the ⋯ menu
|
||||
(right-11 clears the 28px trigger at right-3) so the two align. */}
|
||||
{agent.ownership === 'team' && (
|
||||
|
||||
@@ -54,6 +54,31 @@ describe('AgentPageHeader sub-nav', () => {
|
||||
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('shows only the tabs the role allows', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentId="a1"
|
||||
agentName="Renewals"
|
||||
currentPage="overview"
|
||||
access={{
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const nav = container.querySelector(
|
||||
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
|
||||
);
|
||||
expect(Array.from(nav?.children ?? []).map((t) => t.textContent)).toEqual([
|
||||
'agents.pageHeader.tabs.overview',
|
||||
'agents.pageHeader.tabs.logs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
|
||||
const onNameClick = vi.fn();
|
||||
act(() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { type AccessFields } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentEditPath as agentEditPathProp,
|
||||
@@ -47,6 +49,11 @@ type AgentPageHeaderProps = {
|
||||
onNameClick?: () => void;
|
||||
/** A status Badge placed after the crumbs. */
|
||||
status?: ReactNode;
|
||||
/**
|
||||
* The agent's access fields: each tab shows only when the role allows its
|
||||
* page. Omitted (a new workflow, not yet loaded), every tab shows.
|
||||
*/
|
||||
access?: (AccessFields & { status?: string }) | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +76,7 @@ export default function AgentPageHeader({
|
||||
agentImage,
|
||||
onNameClick,
|
||||
status,
|
||||
access,
|
||||
}: AgentPageHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -81,20 +89,26 @@ export default function AgentPageHeader({
|
||||
id: 'overview' as const,
|
||||
label: t('agents.pageHeader.tabs.overview'),
|
||||
href: editPath,
|
||||
action: 'view',
|
||||
},
|
||||
{
|
||||
id: 'logs' as const,
|
||||
label: t('agents.pageHeader.tabs.logs'),
|
||||
href: agentId ? agentLogsPath(agentId) : '#',
|
||||
action: 'view_logs',
|
||||
},
|
||||
{
|
||||
id: 'schedules' as const,
|
||||
label: t('agents.pageHeader.tabs.schedules'),
|
||||
href: agentId ? agentSchedulesPath(agentId) : '#',
|
||||
action: 'manage_schedules',
|
||||
},
|
||||
],
|
||||
[agentId, editPath, t],
|
||||
);
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !access || canAgent(access, tab.action),
|
||||
);
|
||||
|
||||
const currentTabLabel =
|
||||
tabs.find((tab) => tab.id === currentPage)?.label ?? '';
|
||||
@@ -182,7 +196,7 @@ export default function AgentPageHeader({
|
||||
!inline && 'border-border border-b',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const state = {
|
||||
preference: {
|
||||
token: null,
|
||||
agents: [] as unknown[],
|
||||
sharedAgents: [],
|
||||
selectedAgent: null,
|
||||
},
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getAgent: vi.fn() }));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { getAgent: mocks.getAgent },
|
||||
}));
|
||||
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentRouteGuard', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
state.preference.agents = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.getAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (action: string, path = '/agents/manage/logs/a1') => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/:page/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action={action}>
|
||||
<div data-testid="page" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="/agents/manage" element={<div data-testid="list" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shows = (id: string) =>
|
||||
container.querySelector(`[data-testid="${id}"]`) !== null;
|
||||
|
||||
it('sends a viewer back to the list without showing the page', async () => {
|
||||
let resolve: (v: unknown) => void = () => undefined;
|
||||
mocks.getAgent.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
await render('view', '/agents/manage/edit/a1');
|
||||
// Nothing of the page while the agent loads.
|
||||
expect(shows('page')).toBe(false);
|
||||
await act(async () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'a1',
|
||||
access: 'viewer',
|
||||
allowed_actions: ['pin', 'use'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(shows('page')).toBe(false);
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an editor open the page', async () => {
|
||||
mocks.getAgent.mockReturnValue(
|
||||
respond({
|
||||
id: 'a1',
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}),
|
||||
);
|
||||
await render('view_logs');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
|
||||
it('decides from the agent list without a fetch when it has the actions', async () => {
|
||||
state.preference.agents = [
|
||||
{ id: 'a1', access: 'viewer', allowed_actions: ['pin', 'use'] },
|
||||
];
|
||||
await render('manage_schedules', '/agents/manage/schedules/a1');
|
||||
expect(mocks.getAgent).not.toHaveBeenCalled();
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends the caller back when the agent does not load', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({}, false));
|
||||
await render('view_logs');
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an owner in when the record has no access fields', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({ id: 'a1' }));
|
||||
await render('view');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
selectAgents,
|
||||
selectSelectedAgent,
|
||||
selectSharedAgents,
|
||||
selectToken,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { agentsListPath } from './paths';
|
||||
import type { Agent } from './types';
|
||||
|
||||
type AgentRouteGuardProps = {
|
||||
/** The action the page needs (`view`, `view_logs`, `manage_schedules`). */
|
||||
action: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an agent's page only for a role that may use it.
|
||||
*
|
||||
* Decides from the agent already in the store when that record carries the
|
||||
* server's `allowed_actions`, else fetches the agent. Nothing of the page
|
||||
* renders until it knows, so a viewer never sees a flash of the edit form.
|
||||
* A caller who may not open the page, or an agent that does not load, goes
|
||||
* back to the agent list.
|
||||
*
|
||||
* @param action The agent action the wrapped page needs.
|
||||
* @param children The page.
|
||||
*/
|
||||
export default function AgentRouteGuard({
|
||||
action,
|
||||
children,
|
||||
}: AgentRouteGuardProps) {
|
||||
const { agentId } = useParams();
|
||||
const token = useSelector(selectToken);
|
||||
const agents = useSelector(selectAgents);
|
||||
const sharedAgents = useSelector(selectSharedAgents);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const stored = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === agentId && agent.allowed_actions);
|
||||
|
||||
const [fetched, setFetched] = useState<{
|
||||
id: string;
|
||||
agent: Agent | null;
|
||||
} | null>(null);
|
||||
|
||||
const needsFetch = Boolean(agentId) && !stored;
|
||||
useEffect(() => {
|
||||
if (!needsFetch || !agentId) return;
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getAgent(agentId, token)
|
||||
.then(async (response: Response) => {
|
||||
const agent = response.ok ? ((await response.json()) as Agent) : null;
|
||||
if (!cancelled) setFetched({ id: agentId, agent });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetched({ id: agentId, agent: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, needsFetch, token]);
|
||||
|
||||
if (!agentId) return <>{children}</>;
|
||||
|
||||
let agent: Agent | null | undefined = stored;
|
||||
if (!agent) {
|
||||
if (fetched?.id !== agentId) return null;
|
||||
agent = fetched.agent;
|
||||
}
|
||||
if (!agent || !canAgent(agent, action))
|
||||
return <Navigate to={agentsListPath()} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => {
|
||||
dispatch: vi.fn(),
|
||||
getAgent: vi.fn(() => jsonResponse({})),
|
||||
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
|
||||
deleteAgent: vi.fn(() => jsonResponse({})),
|
||||
guardrailsProps: vi.fn(),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
@@ -53,7 +55,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getAgent: mocks.getAgent,
|
||||
createAgent: mocks.createAgent,
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
deleteAgent: mocks.deleteAgent,
|
||||
createPrompt: () => jsonResponse({}),
|
||||
},
|
||||
}));
|
||||
@@ -97,13 +99,29 @@ vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
|
||||
vi.mock('./AgentPreview', () => ({ default: () => null }));
|
||||
vi.mock('../settings/Prompts', () => ({ default: () => null }));
|
||||
vi.mock('./components/GuardrailsSection', () => ({
|
||||
default: () => null,
|
||||
default: (props: { disabled?: boolean }) => {
|
||||
mocks.guardrailsProps(props);
|
||||
return null;
|
||||
},
|
||||
guardrailsIncomplete: () => false,
|
||||
}));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-delete" onClick={handleSubmit}>
|
||||
confirm
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({
|
||||
default: () => <div data-testid="section-pills" />,
|
||||
@@ -534,3 +552,178 @@ describe('NewAgent form', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewAgent gating by role', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const OWNER = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.getAgent.mockReset();
|
||||
mocks.getAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.deleteAgent.mockReset();
|
||||
mocks.deleteAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.guardrailsProps.mockClear();
|
||||
});
|
||||
|
||||
const renderEdit = async (access: 'owner' | 'editor', allowed: string[]) => {
|
||||
mocks.getAgent.mockImplementation(() =>
|
||||
jsonResponse({
|
||||
id: 'agent-1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
prompt_id: 'default',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/edit/:agentId"
|
||||
element={<NewAgent mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttonByText = (text: string) =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
);
|
||||
|
||||
const menuLabels = async () => {
|
||||
const menu = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.form.buttons.moreActions"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((item) => item.textContent);
|
||||
};
|
||||
|
||||
const lastGuardrailsDisabled = () =>
|
||||
mocks.guardrailsProps.mock.calls.at(-1)?.[0].disabled;
|
||||
|
||||
it('gives the owner Share, Access details and the danger zone', async () => {
|
||||
await renderEdit('owner', OWNER);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeDefined();
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.accessDetails',
|
||||
'agents.shareWithTeam',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Share and Delete from an editor but keeps Access details', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeUndefined();
|
||||
expect(await menuLabels()).toEqual(['agents.form.buttons.accessDetails']);
|
||||
});
|
||||
|
||||
it('lets an editor change guardrails and quotas', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(lastGuardrailsDisabled()).toBe(false);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const switches =
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="switch"]');
|
||||
expect(switches.length).toBeGreaterThan(0);
|
||||
for (const s of Array.from(switches)) expect(s.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('locks guardrails and quotas without edit_policy', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'edit_policy'),
|
||||
);
|
||||
expect(lastGuardrailsDisabled()).toBe(true);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const token = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.enterTokenLimit"]',
|
||||
)!;
|
||||
const tokenSwitch = token
|
||||
.closest('[data-slot="setting-row"]')
|
||||
?.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
expect(tokenSwitch?.disabled).toBe(true);
|
||||
expect(token.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('hides Access details without manage_access_details', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'manage_access_details'),
|
||||
);
|
||||
expect(await menuLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a failed delete in a toast instead of throwing', async () => {
|
||||
mocks.deleteAgent.mockImplementation(() =>
|
||||
jsonResponse({ message: 'Only the owner can delete' }, false),
|
||||
);
|
||||
await renderEdit('owner', OWNER);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.dangerZone.deleteButton')!.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-delete"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import AgentDetailsModal from '../modals/AgentDetailsModal';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Prompt } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import {
|
||||
selectAgentFolders,
|
||||
selectSelectedAgent,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
import PromptsModal from '../preferences/PromptsModal';
|
||||
import Prompts from '../settings/Prompts';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import Upload from '../upload/Upload';
|
||||
import {
|
||||
selectedSourceIdsFromAgent,
|
||||
@@ -78,7 +80,7 @@ import {
|
||||
} from '../utils/sourceUtils';
|
||||
import {
|
||||
getToolDisplayName,
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
} from '../utils/toolUtils';
|
||||
import { agentsListPath } from './paths';
|
||||
import GuardrailsSection, {
|
||||
@@ -359,9 +361,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string) => {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
navigateBackToAgents();
|
||||
try {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) {
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: await extractApiError(response, t('agents.deleteFailed')),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigateBackToAgents();
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
@@ -588,7 +608,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Hide workflow-only builtins (e.g. read_document) from the classic
|
||||
// agent picker; they belong to the workflow-node picker only.
|
||||
const visibleTools = (data.tools as UserToolType[]).filter(
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
);
|
||||
const devicesById = new Map<
|
||||
string,
|
||||
@@ -829,6 +849,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
|
||||
|
||||
const isPublished = agent.status === 'published';
|
||||
// What the caller's role allows on this agent (`allowed_actions` from the
|
||||
// API). A new agent carries no access fields, so it reads as the owner's.
|
||||
const canEditPolicy = can(agent, 'edit_policy');
|
||||
// Save on a published agent is an edit; on a draft or a new agent the main
|
||||
// button publishes it.
|
||||
const canSubmit = can(agent, effectiveMode === 'edit' ? 'edit' : 'publish');
|
||||
const agentDisplayName =
|
||||
agent.name?.trim() || t('agents.pageHeader.fallbackName');
|
||||
|
||||
@@ -845,7 +871,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
onClick: () => setPreviewOpen(true),
|
||||
},
|
||||
]),
|
||||
...(modeConfig[effectiveMode].showAccessDetails
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
can(agent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
@@ -853,10 +880,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Sharing is owner-only — hidden for agents shared into the workspace by
|
||||
// a team (ownership === 'team').
|
||||
// Sharing is the owner's, unless the owner lets editors share.
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
agent.ownership !== 'team' &&
|
||||
can(agent, 'share') &&
|
||||
agent.id
|
||||
? [
|
||||
{
|
||||
@@ -882,7 +908,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{modeConfig[effectiveMode].showSaveDraft && (
|
||||
{modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -907,17 +933,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1369,7 +1397,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_token_mode}
|
||||
disabled={!agent.limited_token_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterTokenLimit',
|
||||
)}
|
||||
@@ -1381,6 +1409,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={tokenLimitSwitchId}
|
||||
checked={agent.limited_token_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1411,7 +1440,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_request_mode}
|
||||
disabled={!agent.limited_request_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterRequestLimit',
|
||||
)}
|
||||
@@ -1423,6 +1452,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={requestLimitSwitchId}
|
||||
checked={agent.limited_request_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1459,15 +1489,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<GuardrailsSection
|
||||
value={agent.config?.guardrails}
|
||||
token={token}
|
||||
// Guardrails are the owner's policy: the update route drops
|
||||
// ``config`` for team members, editors included. Leaving the
|
||||
// controls live for editors let them save a change the server
|
||||
// silently discarded, and the success toast said it had worked.
|
||||
disabled={Boolean(agent.team_access)}
|
||||
// Guardrails are policy (`edit_policy`): editors and the owner
|
||||
// change them; anyone else sees them read-only.
|
||||
disabled={!canEditPolicy}
|
||||
disabledNotice={
|
||||
agent.team_access
|
||||
? t('agents.form.guardrails.ownerOnly')
|
||||
: undefined
|
||||
canEditPolicy ? undefined : t('agents.form.guardrails.readOnly')
|
||||
}
|
||||
onChange={(guardrails) =>
|
||||
setAgent({
|
||||
@@ -1476,29 +1502,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
{modeConfig[effectiveMode].showDelete && agent.id && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
<SectionHeader
|
||||
{modeConfig[effectiveMode].showDelete &&
|
||||
agent.id &&
|
||||
can(agent, 'delete') && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<SectionHeader
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
message={t('agents.deleteConfirmation')}
|
||||
@@ -1595,16 +1623,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
title={t('agents.form.preview.publishTitle')}
|
||||
description={t('agents.form.preview.publishDescription')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
can(agent, 'publish') ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { canAgent, canOpenAgentEditor } from './agentAccess';
|
||||
import type { Agent } from './types';
|
||||
|
||||
const agent = (fields: Partial<Agent>) => ({ id: 'a1', ...fields }) as Agent;
|
||||
|
||||
describe('canOpenAgentEditor', () => {
|
||||
it('follows the list copy of the agent when there is one', () => {
|
||||
const listed = agent({ access: 'editor', allowed_actions: ['view'] });
|
||||
expect(canOpenAgentEditor(agent({}), [listed])).toBe(true);
|
||||
const viewer = agent({ access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(canOpenAgentEditor(agent({}), [viewer])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the selected agent when it carries the actions', () => {
|
||||
expect(
|
||||
canOpenAgentEditor(
|
||||
agent({ access: 'viewer', allowed_actions: ['pin', 'use'] }),
|
||||
[],
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenAgentEditor(agent({ access: 'owner', allowed_actions: ['view'] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an unlisted agent with no access fields', () => {
|
||||
expect(canOpenAgentEditor(agent({}), [])).toBe(false);
|
||||
expect(canOpenAgentEditor(null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAgent', () => {
|
||||
const all = ['view', 'view_logs', 'manage_schedules', 'pin'];
|
||||
it('follows allowed_actions on a published agent', () => {
|
||||
const a = agent({
|
||||
status: 'published',
|
||||
access: 'editor',
|
||||
allowed_actions: all,
|
||||
});
|
||||
expect(all.every((x) => canAgent(a, x))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops Logs, Schedules and Pin on a draft, keeps the editor', () => {
|
||||
const a = agent({ status: 'draft', access: 'owner', allowed_actions: all });
|
||||
expect(canAgent(a, 'view')).toBe(true);
|
||||
expect(canAgent(a, 'view_logs')).toBe(false);
|
||||
expect(canAgent(a, 'manage_schedules')).toBe(false);
|
||||
expect(canAgent(a, 'pin')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an agent with no status yet as published', () => {
|
||||
expect(canAgent(agent({ allowed_actions: all }), 'view_logs')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { can, type AccessFields } from '../utils/accessUtils';
|
||||
import type { Agent } from './types';
|
||||
|
||||
/**
|
||||
* Whether the chat header and the phone top bar offer Edit for an agent.
|
||||
*
|
||||
* Prefers the agent list's copy, which carries the server's access fields.
|
||||
* An agent that is not in the list (a template, a link-shared agent) must
|
||||
* carry `allowed_actions` itself; without them it gets no Edit, so a record
|
||||
* with no access fields is never taken for the caller's own.
|
||||
*
|
||||
* @param agent The agent the chat is with.
|
||||
* @param agents The caller's agent list (own and team-shared).
|
||||
* @returns True when the role may open the agent's edit page.
|
||||
*/
|
||||
export function canOpenAgentEditor(
|
||||
agent: Agent | null | undefined,
|
||||
agents?: Agent[] | null,
|
||||
): boolean {
|
||||
if (!agent?.id) return false;
|
||||
const listed = agents?.find((a) => a.id === agent.id);
|
||||
if (listed) return can(listed, 'view');
|
||||
return Boolean(agent.allowed_actions) && can(agent, 'view');
|
||||
}
|
||||
|
||||
/** Actions that only make sense once an agent is published. */
|
||||
const PUBLISHED_ONLY = new Set(['view_logs', 'manage_schedules', 'pin']);
|
||||
|
||||
/**
|
||||
* `can()` for an agent, minus what a draft can't have: a draft has no runs
|
||||
* to log, no schedule that fires and nothing to pin, so Logs, Schedules and
|
||||
* Pin wait until it's published. An agent without a status (a record still
|
||||
* loading) is treated as published.
|
||||
*
|
||||
* @param agent The agent, with its access fields and status.
|
||||
* @param action The agent action to check.
|
||||
* @returns True when the role allows it and the agent's state makes sense.
|
||||
*/
|
||||
export function canAgent(
|
||||
agent: (AccessFields & { status?: string }) | null | undefined,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (!agent) return false;
|
||||
if (
|
||||
PUBLISHED_ONLY.has(action) &&
|
||||
agent.status &&
|
||||
agent.status !== 'published'
|
||||
)
|
||||
return false;
|
||||
return can(agent, action);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import AgentLogs from './AgentLogs';
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
import AgentsList from './AgentsList';
|
||||
import NewAgent from './NewAgent';
|
||||
import { AGENTS_MANAGE_ROOT } from './paths';
|
||||
@@ -31,13 +32,40 @@ export default function Agents() {
|
||||
<Route path="manage/team" element={<AgentsList />} />
|
||||
<Route path="manage/discovered" element={<AgentsList />} />
|
||||
<Route path="manage/new" element={<NewAgent mode="new" />} />
|
||||
<Route path="manage/edit/:agentId" element={<NewAgent mode="edit" />} />
|
||||
<Route path="manage/logs/:agentId" element={<AgentLogs />} />
|
||||
<Route path="manage/schedules/:agentId" element={<SchedulesView />} />
|
||||
{/* An agent's pages open only for a role that may use them; the
|
||||
guard sends anyone else back to the list. */}
|
||||
<Route
|
||||
path="manage/edit/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<NewAgent mode="edit" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/logs/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view_logs">
|
||||
<AgentLogs />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/schedules/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="manage_schedules">
|
||||
<SchedulesView />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="manage/workflow/new" element={<WorkflowBuilder />} />
|
||||
<Route
|
||||
path="manage/workflow/edit/:agentId"
|
||||
element={<WorkflowBuilder />}
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<WorkflowBuilder />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Using an agent someone shared. */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
export type ToolSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,6 +32,9 @@ export type Agent = {
|
||||
// sharing with a team) are gated on 'user'.
|
||||
ownership?: 'user' | 'team';
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
/** The caller's role and the actions it allows (see `utils/accessUtils`). */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: AccessFields['allowed_actions'];
|
||||
// Owner-agnostic display names resolved server-side (GET /api/get_agent) so a
|
||||
// team member viewing a shared agent sees the owner's prompt/source names
|
||||
// instead of a blank prompt / "External KB" (the client can only resolve
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { CircleAlert, Link, Pencil, Play, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Link,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -31,6 +39,8 @@ import userService from '../../api/services/userService';
|
||||
import AgentDetailsModal from '../../modals/AgentDetailsModal';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import ShareToTeamModal from '../../teams/ShareToTeamModal';
|
||||
import { can } from '../../utils/accessUtils';
|
||||
import {
|
||||
selectSourceDocs,
|
||||
selectToken,
|
||||
@@ -85,6 +95,7 @@ import {
|
||||
validateJsonSchemaConfig,
|
||||
} from './workflowHelpers';
|
||||
import { selectWorkflowPreviewStatus } from './workflowPreviewSlice';
|
||||
import { canAddToolToOwn } from '../../utils/toolUtils';
|
||||
|
||||
import type { Model } from '../../models/types';
|
||||
|
||||
@@ -219,6 +230,7 @@ function WorkflowBuilderInner() {
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
const [currentAgent, setCurrentAgent] = useState<Agent>(
|
||||
createEmptyWorkflowAgent(),
|
||||
@@ -789,7 +801,10 @@ function WorkflowBuilderInner() {
|
||||
const toolsResponse = await userService.getUserTools(token);
|
||||
if (toolsResponse.ok) {
|
||||
const toolsData = await toolsResponse.json();
|
||||
setAvailableTools(toolsData.tools);
|
||||
// Shared tools the caller can't add to their own agents stay out.
|
||||
setAvailableTools(
|
||||
(toolsData.tools as UserTool[]).filter(canAddToolToOwn),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models or tools:', error);
|
||||
@@ -1517,8 +1532,11 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}, [detailsSaveRequested, persistWorkflow]);
|
||||
|
||||
// Save on a saved workflow is an edit; the first save publishes it. A new
|
||||
// workflow has no access fields, so it reads as the owner's.
|
||||
const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish');
|
||||
const isPrimaryActionDisabled =
|
||||
isPublishing || (canManageAgent && !hasSavableChanges);
|
||||
!canSubmit || isPublishing || (canManageAgent && !hasSavableChanges);
|
||||
const primaryActionLabel = canManageAgent
|
||||
? t('agents.form.buttons.save')
|
||||
: t('agents.form.buttons.publish');
|
||||
@@ -1642,6 +1660,7 @@ function WorkflowBuilderInner() {
|
||||
agentEditPath={agentEditPath(effectiveAgentId, true)}
|
||||
agentImage={currentAgentImage}
|
||||
currentPage="overview"
|
||||
access={canManageAgent ? currentAgent : undefined}
|
||||
onNameClick={openDetails}
|
||||
status={
|
||||
canManageAgent && currentAgent.status !== 'draft' ? (
|
||||
@@ -1678,16 +1697,18 @@ function WorkflowBuilderInner() {
|
||||
<Play />
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('agents.form.buttons.moreActions')}
|
||||
@@ -1697,13 +1718,26 @@ function WorkflowBuilderInner() {
|
||||
icon: Pencil,
|
||||
onClick: openDetails,
|
||||
},
|
||||
...(canManageAgent
|
||||
...(canManageAgent && can(currentAgent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
icon: Link,
|
||||
onClick: () => setAgentDetails('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'share')
|
||||
? [
|
||||
{
|
||||
label: t('agents.shareWithTeam'),
|
||||
icon: Users,
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'delete')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.delete'),
|
||||
icon: Trash2,
|
||||
@@ -1927,6 +1961,14 @@ function WorkflowBuilderInner() {
|
||||
cancelLabel={t('agents.form.buttons.cancel')}
|
||||
variant="destructive"
|
||||
/>
|
||||
{shareModalOpen && effectiveAgentId && (
|
||||
<ShareToTeamModal
|
||||
resourceType="agent"
|
||||
resourceId={effectiveAgentId}
|
||||
resourceName={workflowName}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{canManageAgent && (
|
||||
<AgentDetailsModal
|
||||
agent={agentForDetails}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type TFunction } from 'i18next';
|
||||
|
||||
import { ConditionCase } from '../types/workflow';
|
||||
import { FilePassing } from './documentConfig';
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
// Names and handles are the user's own text: React escapes on render, so
|
||||
// i18next must not escape them first.
|
||||
@@ -32,6 +33,10 @@ export interface UserTool {
|
||||
// Workflow-only builtins (e.g. read_document) are kept here; the classic
|
||||
// agent picker filters them out.
|
||||
workflow_only?: boolean;
|
||||
/** Team sharing: shared tools the caller can't use in their own agents are hidden. */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: string[];
|
||||
ownership?: AccessFields['ownership'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ const endpoints = {
|
||||
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}`,
|
||||
RESOURCE_SETTINGS: '/api/resource_settings',
|
||||
ALL_TEAMS: '/api/admin/teams',
|
||||
PROMPTS: '/api/get_prompts',
|
||||
CREATE_PROMPT: '/api/create_prompt',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const put = vi.fn();
|
||||
const del = vi.fn();
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => get(...args),
|
||||
post: (...args: unknown[]) => post(...args),
|
||||
put: (...args: unknown[]) => put(...args),
|
||||
delete: (...args: unknown[]) => del(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import teamsService, { TeamsApiError } from './teamsService';
|
||||
|
||||
const response = (status: number, body: unknown) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('teamsService', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
put.mockReset();
|
||||
del.mockReset();
|
||||
});
|
||||
|
||||
it('returns the parsed body on 2xx', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, teams: [] }));
|
||||
await expect(teamsService.list('t')).resolves.toEqual({
|
||||
success: true,
|
||||
teams: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the server message on 403', async () => {
|
||||
del.mockResolvedValue(
|
||||
response(403, { success: false, message: 'Only the owner can delete' }),
|
||||
);
|
||||
const error = await teamsService.remove('team-1', 't').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe('Only the owner can delete');
|
||||
});
|
||||
|
||||
it('throws on a non-2xx with no JSON body', async () => {
|
||||
get.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
const error = await teamsService.list('t').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(500);
|
||||
});
|
||||
|
||||
it('reads and writes resource settings', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.getResourceSettings('agent', 'a 1', 't');
|
||||
expect(get.mock.calls[0][0]).toBe(
|
||||
'/api/resource_settings?resource_type=agent&resource_id=a%201',
|
||||
);
|
||||
put.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.updateResourceSettings(
|
||||
'agent',
|
||||
'a1',
|
||||
{ editors_can_share: true },
|
||||
't',
|
||||
);
|
||||
expect(put.mock.calls[0][0]).toBe('/api/resource_settings');
|
||||
expect(put.mock.calls[0][1]).toEqual({
|
||||
resource_type: 'agent',
|
||||
resource_id: 'a1',
|
||||
settings: { editors_can_share: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,13 +25,73 @@ export type ResourceShare = {
|
||||
team_slug?: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
// A grant row from GET /api/teams/<id>/grants. The server resolves names and
|
||||
// labels, plus the caller's own access to the resource (`caller`).
|
||||
export type TeamGrant = {
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
resource_name?: string | null;
|
||||
owner_id?: string | null;
|
||||
owner_label?: string | null;
|
||||
target_user_label?: string | null;
|
||||
created_at?: string | null;
|
||||
granted_by?: string | null;
|
||||
granted_by_label?: string | null;
|
||||
caller?: {
|
||||
access: 'owner' | 'editor' | 'viewer';
|
||||
allowed_actions: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
// One owner switch on a resource (`resource_share_settings`).
|
||||
export type ResourceSetting = { key: string; value: boolean; default: boolean };
|
||||
|
||||
export type ResourceSettingsResponse = {
|
||||
success: boolean;
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
settings: ResourceSetting[];
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
/** A non-2xx teams API response; `message` is the server's own message. */
|
||||
export class TeamsApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'TeamsApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// apiClient resolves to the raw fetch Response (the app convention); services
|
||||
// consumed by slices/components parse the JSON here so callers get plain data.
|
||||
// A non-2xx status rejects with the server's message, so callers never mistake
|
||||
// a 403/404 for success.
|
||||
const json = async (response: Response | unknown) => {
|
||||
const r = response as Response;
|
||||
if (!r || !('json' in r) || typeof r.json !== 'function') return r as unknown;
|
||||
if (typeof r.ok === 'boolean' && !r.ok) {
|
||||
let message = `Request failed (${r.status})`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.message === 'string' && body.message) {
|
||||
message = body.message;
|
||||
} else if (body && typeof body.error === 'string' && body.error) {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: keep the generic message.
|
||||
}
|
||||
throw new TeamsApiError(r.status, message);
|
||||
}
|
||||
return r.json();
|
||||
};
|
||||
|
||||
@@ -154,6 +214,37 @@ const teamsService = {
|
||||
),
|
||||
),
|
||||
|
||||
getResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.get(
|
||||
`${endpoints.USER.RESOURCE_SETTINGS}?resource_type=${resourceType}&resource_id=${encodeURIComponent(
|
||||
resourceId,
|
||||
)}`,
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
updateResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
settings: Record<string, boolean>,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.put(
|
||||
endpoints.USER.RESOURCE_SETTINGS,
|
||||
{
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId,
|
||||
settings,
|
||||
},
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
|
||||
listAll: async (token: string | null): Promise<any> =>
|
||||
json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)),
|
||||
};
|
||||
|
||||
@@ -843,4 +843,39 @@ describe('Chunks', () => {
|
||||
await act(async () => buttonByText('retry')!.click());
|
||||
expect(tile()).not.toBeNull();
|
||||
});
|
||||
const openReaderMenu = async () => {
|
||||
const trigger = buttonByLabel('settings.sources.menuAlt')!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only (canEdit false): no Add chunk, Edit or Delete; reading stays', async () => {
|
||||
await render({ embedded: true, canEdit: false });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeUndefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(container.querySelector('h2')?.textContent).toBe(
|
||||
'Late pickup clause',
|
||||
);
|
||||
expect(buttonByText('modals.chunk.edit')).toBeUndefined();
|
||||
expect(buttonByLabel('settings.sources.nextChunk')).not.toBeNull();
|
||||
expect(await openReaderMenu()).toEqual(['settings.sources.copyText']);
|
||||
});
|
||||
|
||||
it('an editor (canEdit true) keeps Add chunk, Edit and Delete', async () => {
|
||||
await render({ embedded: true, canEdit: true });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(buttonByText('modals.chunk.edit')).toBeDefined();
|
||||
expect(await openReaderMenu()).toEqual([
|
||||
'settings.sources.copyText',
|
||||
'modals.chunk.delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,11 @@ interface ChunksProps {
|
||||
/** Where the open chunk is; see {@link OpenChunkPosition}. */
|
||||
onOpenChunkChange?: (position: OpenChunkPosition) => void;
|
||||
controllerRef?: React.MutableRefObject<ChunksController | null>;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides Add chunk, Edit and Delete; reading, copying and paging stay.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type SheetMode = 'edit' | 'add';
|
||||
@@ -103,6 +108,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
embedded = false,
|
||||
onOpenChunkChange,
|
||||
controllerRef,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -556,15 +562,17 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -675,16 +683,18 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
disabled={!canGoNext}
|
||||
onClick={() => goToChunk(openPosition + 1)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('settings.sources.menuAlt')}
|
||||
@@ -702,12 +712,16 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive',
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { tree } = vi.hoisted(() => ({
|
||||
tree: {
|
||||
structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
@@ -24,10 +33,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getDirectoryStructure: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
provider: 'google_drive',
|
||||
directory_structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
},
|
||||
directory_structure: tree.structure,
|
||||
}),
|
||||
})),
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
@@ -144,4 +150,85 @@ describe('ConnectorTree and FileTree headers', () => {
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
|
||||
});
|
||||
const openFirstRowMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'tbody button[aria-label="settings.sources.menuAlt"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only ConnectorTree hides Sync, keeps headerAction', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.sync');
|
||||
});
|
||||
|
||||
it('read-only FileTree hides Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).not.toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('an editable FileTree keeps Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('a read-only one-file FileTree has no header menu and no Add chunk', async () => {
|
||||
tree.structure = { 'a.docx': { type: 'docx' } };
|
||||
try {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="settings.sources.menuAlt"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
} finally {
|
||||
tree.structure = {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@ interface ConnectorTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Sync and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
// Provider names are brand names, so they are not translated.
|
||||
@@ -64,6 +69,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -145,28 +151,30 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -188,6 +196,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
badge={
|
||||
|
||||
@@ -38,6 +38,11 @@ interface FileTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Add file, file and folder Delete (row and header menus) and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const FileTree: React.FC<FileTreeProps> = ({
|
||||
@@ -49,6 +54,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -226,6 +232,8 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
isFile,
|
||||
defaultViewOption,
|
||||
}: RowMenuContext): MenuOption[] => {
|
||||
// Read-only: View only, so a one-file source draws no header menu.
|
||||
if (!canEdit) return [defaultViewOption];
|
||||
return [
|
||||
defaultViewOption,
|
||||
{
|
||||
@@ -248,7 +256,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
{!isProcessing ? (
|
||||
{canEdit && !isProcessing ? (
|
||||
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
|
||||
{t('settings.sources.addFile')}
|
||||
</Button>
|
||||
@@ -281,6 +289,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
columnOrder="size-first"
|
||||
|
||||
@@ -79,6 +79,8 @@ interface GraphViewProps {
|
||||
active?: boolean;
|
||||
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
|
||||
@@ -110,6 +112,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
onSelect,
|
||||
active = true,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useMediaQuery();
|
||||
@@ -590,6 +593,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
overview={data}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ import {
|
||||
} from '../constants/fileUpload';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
|
||||
import { isChatToolVisible } from '../utils/toolUtils';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { isOwner } from '../utils/accessUtils';
|
||||
import { isChatPickerToolVisible, toolInChat } from '../utils/toolUtils';
|
||||
|
||||
const generateId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
@@ -1551,7 +1553,7 @@ export default function MessageInput({
|
||||
.getUserTools(token)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const filtered = (data.tools || []).filter(isChatToolVisible);
|
||||
const filtered = (data.tools || []).filter(isChatPickerToolVisible);
|
||||
setUserTools(filtered);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1568,25 +1570,53 @@ export default function MessageInput({
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: <ToolIcon name={tool.name} className="size-5" />,
|
||||
description:
|
||||
!isOwner(tool) && tool.shared_via
|
||||
? t('settings.tools.sharedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: tool.shared_via,
|
||||
})
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
.filter((tool) => toolInChat(tool))
|
||||
.map((tool) => tool.id);
|
||||
|
||||
// Ticks at once and unticks again when the server refuses it.
|
||||
const setToolInChat = (id: string, value: boolean) =>
|
||||
setUserTools((prev) =>
|
||||
prev.map((tool) =>
|
||||
tool.id !== id
|
||||
? tool
|
||||
: isOwner(tool)
|
||||
? { ...tool, status: value, in_chat: value }
|
||||
: { ...tool, in_chat: value },
|
||||
),
|
||||
);
|
||||
|
||||
const handleToggleTool = (id: string) => {
|
||||
const tool = userTools.find((t) => t.id === id);
|
||||
if (!tool) return;
|
||||
const newStatus = !tool.status;
|
||||
const newStatus = !toolInChat(tool);
|
||||
setToolInChat(id, newStatus);
|
||||
const fail = () => {
|
||||
setToolInChat(id, !newStatus);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.statusUpdateFailed'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
userService
|
||||
.updateToolStatus({ id, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)),
|
||||
);
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) fail();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
fail();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ interface GraphChunkSheetProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Called after a saved edit, to refetch the node detail. */
|
||||
onSaved?: () => void;
|
||||
/** Whether the read drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ export default function GraphChunkSheet({
|
||||
onClose,
|
||||
onOpenInFiles,
|
||||
onSaved,
|
||||
canEdit = true,
|
||||
}: GraphChunkSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -91,6 +94,8 @@ export default function GraphChunkSheet({
|
||||
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
|
||||
: '';
|
||||
|
||||
const showOpenInFiles = !!onOpenInFiles && !!path;
|
||||
|
||||
const close = () => {
|
||||
setEditing(false);
|
||||
setSaveFailed(false);
|
||||
@@ -173,32 +178,44 @@ export default function GraphChunkSheet({
|
||||
highlight={highlight}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{onOpenInFiles && path ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="lg" shape="pill" onClick={startEdit}>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* A reader with nothing to open or edit gets no action row. */}
|
||||
{showOpenInFiles || canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{showOpenInFiles ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles?.(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<SourceEditSheet
|
||||
open={editing}
|
||||
open={canEdit && editing}
|
||||
onClose={leaveEdit}
|
||||
title={t('settings.sources.graphrag.view.editChunk')}
|
||||
description={meta || undefined}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function GraphEntities({
|
||||
onShowInGraph,
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}: {
|
||||
docId: string;
|
||||
fold: FoldedGraphTypes;
|
||||
@@ -70,6 +71,8 @@ export default function GraphEntities({
|
||||
overview?: ForceGraphData;
|
||||
/** Show a chunk's file on the Files tab. */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -157,6 +160,7 @@ export default function GraphEntities({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -69,6 +69,8 @@ interface GraphNodePanelProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Refetch the detail after a chunk edit, keeping it on screen. */
|
||||
onChunkSaved?: () => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +107,7 @@ export default function GraphNodePanel({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit = true,
|
||||
}: GraphNodePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = detail?.name ?? node.name;
|
||||
@@ -166,6 +169,7 @@ export default function GraphNodePanel({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -193,6 +197,7 @@ function NodeDetailBody({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit,
|
||||
}: {
|
||||
docId: string;
|
||||
detail: GraphNodeDetail;
|
||||
@@ -201,6 +206,7 @@ function NodeDetailBody({
|
||||
overview?: ForceGraphData;
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
onChunkSaved?: () => void;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [descriptionOpen, setDescriptionOpen] = useState(false);
|
||||
@@ -413,6 +419,7 @@ function NodeDetailBody({
|
||||
onClose={() => setOpenChunk(null)}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('../FileTree', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
initialPath?: string;
|
||||
actionsTarget?: HTMLElement | null;
|
||||
onCrumbsChange?: (crumbs: { label: string }[]) => void;
|
||||
@@ -51,6 +52,7 @@ vi.mock('../FileTree', async () => {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-tree"
|
||||
data-can-edit={String(props.canEdit)}
|
||||
data-initial-path={props.initialPath ?? ''}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
@@ -72,6 +74,7 @@ vi.mock('../Chunks', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
documentId: string;
|
||||
onOpenChunkChange?: (position: number | 'unplaced' | null) => void;
|
||||
controllerRef?: { current: { closeChunk: () => boolean } | null };
|
||||
@@ -90,7 +93,11 @@ vi.mock('../Chunks', async () => {
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div data-testid="chunks" data-doc={props.documentId}>
|
||||
<div
|
||||
data-testid="chunks"
|
||||
data-doc={props.documentId}
|
||||
data-can-edit={String(props.canEdit)}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
<button type="button" onClick={() => setOpen(2)}>
|
||||
OPEN CHUNK
|
||||
@@ -104,7 +111,9 @@ vi.mock('../Chunks', async () => {
|
||||
};
|
||||
});
|
||||
vi.mock('../ConnectorTree', () => ({
|
||||
default: () => <div data-testid="connector-tree" />,
|
||||
default: (props: { canEdit?: boolean }) => (
|
||||
<div data-testid="connector-tree" data-can-edit={String(props.canEdit)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
@@ -207,6 +216,7 @@ describe('GraphSourceView', () => {
|
||||
sourceType?: string,
|
||||
onBack = vi.fn(),
|
||||
isNested?: boolean,
|
||||
canEdit?: boolean,
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -215,6 +225,7 @@ describe('GraphSourceView', () => {
|
||||
sourceName="Key Accounts"
|
||||
sourceType={sourceType}
|
||||
isNested={isNested}
|
||||
canEdit={canEdit}
|
||||
onBackToDocuments={onBack}
|
||||
headerAction={<button type="button">Test retrieval</button>}
|
||||
/>,
|
||||
@@ -554,6 +565,75 @@ describe('GraphSourceView', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('passes canEdit to every Files view', async () => {
|
||||
const canEditOf = (testId: string) =>
|
||||
container
|
||||
.querySelector(`[data-testid="${testId}"]`)
|
||||
?.getAttribute('data-can-edit');
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openTab('settings.sources.graphrag.view.tabs.files');
|
||||
expect(canEditOf('file-tree')).toBe('false');
|
||||
await render('connector:file', vi.fn(), true, false);
|
||||
expect(canEditOf('connector-tree')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, false);
|
||||
expect(canEditOf('chunks')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, true);
|
||||
expect(canEditOf('chunks')).toBe('true');
|
||||
});
|
||||
|
||||
const openEntityChunk = async () => {
|
||||
service.getSourceGraphNode.mockResolvedValue(
|
||||
ok({
|
||||
node: {
|
||||
id: 'n',
|
||||
name: 'Nordhaven',
|
||||
type: 'Company',
|
||||
degree: 9,
|
||||
relationships: [],
|
||||
chunks: [
|
||||
{
|
||||
chunk_id: 'c1',
|
||||
text: 'Nordhaven runs the lane.',
|
||||
metadata: { source: 'briefs/Nordhaven.md' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await openTab('settings.sources.graphrag.view.tabs.entities');
|
||||
const row = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
|
||||
r.textContent?.includes('Nordhaven'),
|
||||
) as HTMLTableRowElement;
|
||||
await act(async () => row.click());
|
||||
await flush();
|
||||
const tile = Array.from(
|
||||
container.querySelectorAll('button[data-slot="card"]'),
|
||||
).find((b) =>
|
||||
b.textContent?.includes('Nordhaven runs the lane.'),
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => tile.click());
|
||||
};
|
||||
|
||||
const drawerButtons = () =>
|
||||
Array.from(document.body.querySelectorAll('[role="dialog"] button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
|
||||
it("a read-only graph's chunk drawer has Open in Files but no Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain(
|
||||
'settings.sources.graphrag.view.openInFiles',
|
||||
);
|
||||
expect(drawerButtons()).not.toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it("an editor's graph chunk drawer keeps Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, true);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it('a new entity filter fetches page 1 once, not the old page first', async () => {
|
||||
service.getSourceGraphNodes.mockImplementation(async () =>
|
||||
ok({
|
||||
|
||||
@@ -50,6 +50,11 @@ interface GraphSourceViewProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control (Test retrieval), right-aligned in the title row. */
|
||||
headerAction?: ReactNode;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides the Files tab's writes and the graph chunk drawer's Edit.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +71,7 @@ export default function GraphSourceView({
|
||||
isNested = true,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
canEdit = true,
|
||||
}: GraphSourceViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -172,6 +178,7 @@ export default function GraphSourceView({
|
||||
const files = !isNested ? (
|
||||
<Chunks
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
documentId={docId}
|
||||
documentName={sourceName}
|
||||
handleGoBack={onBackToDocuments}
|
||||
@@ -181,6 +188,7 @@ export default function GraphSourceView({
|
||||
) : sourceType === 'connector:file' ? (
|
||||
<ConnectorTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -191,6 +199,7 @@ export default function GraphSourceView({
|
||||
) : (
|
||||
<FileTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -277,6 +286,7 @@ export default function GraphSourceView({
|
||||
onSelect={setSelected}
|
||||
active={tab === 'graph'}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="entities" className="mt-4">
|
||||
@@ -286,6 +296,7 @@ export default function GraphSourceView({
|
||||
onShowInGraph={showInGraph}
|
||||
overview={data}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-4">
|
||||
|
||||
@@ -337,6 +337,17 @@ describe('TreeBrowser', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('canEdit false reaches the chunk list: no Add chunk', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } }, { canEdit: false });
|
||||
expect(chunkListOpen()).toBe(true);
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('an editable tree keeps Add chunk on the chunk list', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } });
|
||||
expect(container.textContent).toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('a failed load says so and retries', async () => {
|
||||
const getDirectoryStructure = vi.mocked(userService.getDirectoryStructure);
|
||||
getDirectoryStructure.mockImplementationOnce(async () => {
|
||||
|
||||
@@ -114,6 +114,11 @@ export interface TreeBrowserProps {
|
||||
* changes.
|
||||
*/
|
||||
initialPath?: string;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides the chunk list's Add, Edit and Delete; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +199,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useLoaderState(true, 500);
|
||||
@@ -688,6 +694,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
fileName={file.name}
|
||||
controllerRef={chunksControllerRef}
|
||||
onOpenChunkChange={setOpenChunkPosition}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,4 +45,15 @@ describe('ListRow', () => {
|
||||
expect(html).not.toContain('px-4 py-3');
|
||||
expect(html).toContain('focus-visible:ring-inset');
|
||||
});
|
||||
|
||||
it('keeps the brand tint on a selected row, hover included', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ListRow interactive selected asChild title="Carrier Rates MCP">
|
||||
<button type="button" />
|
||||
</ListRow>,
|
||||
);
|
||||
expect(html).toContain('bg-secondary hover:bg-secondary');
|
||||
expect(html).not.toContain('hover:bg-accent');
|
||||
expect(html).toContain('aria-current="true"');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ type ListRowProps = Omit<React.ComponentProps<'li'>, 'title'> & {
|
||||
trailing?: React.ReactNode;
|
||||
/** The whole row is a target: hover fill and an inset focus ring. */
|
||||
interactive?: boolean;
|
||||
/**
|
||||
* The row whose detail is open beside the list (the team page's shared
|
||||
* resources drawer): the `bg-secondary` brand tint, kept on hover, and
|
||||
* `aria-current`, like a selected TableRow.
|
||||
*/
|
||||
selected?: boolean;
|
||||
/**
|
||||
* `sm` is the dense row of a narrow side panel (the graph node panel's
|
||||
* relationships): 6px / 8px padding, rounded, top-aligned so a small
|
||||
@@ -45,6 +51,7 @@ function ListRow({
|
||||
description,
|
||||
trailing,
|
||||
interactive = false,
|
||||
selected = false,
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
className,
|
||||
@@ -59,7 +66,9 @@ function ListRow({
|
||||
// Inset, because a row list usually sits in an overflow-hidden rounded
|
||||
// box that would clip an outer ring.
|
||||
interactive &&
|
||||
'hover:bg-accent focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
'focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
interactive && !selected && 'hover:bg-accent',
|
||||
selected && 'bg-secondary hover:bg-secondary',
|
||||
!asChild && className,
|
||||
);
|
||||
const content = (
|
||||
@@ -80,7 +89,10 @@ function ListRow({
|
||||
if (asChild) {
|
||||
return (
|
||||
<li data-slot="list-row" className={className} {...props}>
|
||||
<Slot.Root className={rowClass}>
|
||||
<Slot.Root
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(
|
||||
children as React.ReactElement<{ children?: React.ReactNode }>,
|
||||
@@ -94,7 +106,12 @@ function ListRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li data-slot="list-row" className={rowClass} {...props}>
|
||||
<li
|
||||
data-slot="list-row"
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { canOpenAgentEditor } from '../agents/agentAccess';
|
||||
import SharedAgentCard from '../agents/SharedAgentCard';
|
||||
import { Agent } from '../agents/types';
|
||||
import ArtifactSidebar from '../components/ArtifactSidebar';
|
||||
@@ -12,6 +13,7 @@ import MessageInput from '../components/MessageInput';
|
||||
import { agentChatPath, agentEditPathFor } from '../agents/paths';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import {
|
||||
selectAgents,
|
||||
selectConversationId,
|
||||
selectSelectedAgent,
|
||||
selectToken,
|
||||
@@ -61,6 +63,7 @@ export default function Conversation() {
|
||||
const status = useSelector(selectStatus);
|
||||
const conversationId = useSelector(selectConversationId);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
const agents = useSelector(selectAgents);
|
||||
const completedAttachments = useSelector(selectCompletedAttachments);
|
||||
const attachments = useSelector(selectAttachments);
|
||||
// A direct send (hero card) that must wait for pending attachments is
|
||||
@@ -401,7 +404,8 @@ export default function Conversation() {
|
||||
<SharedAgentCard
|
||||
agent={selectedAgent}
|
||||
onEdit={
|
||||
selectedAgent.id
|
||||
// Only a role that may open the edit page gets Edit.
|
||||
canOpenAgentEditor(selectedAgent, agents)
|
||||
? () => navigate(agentEditPathFor(selectedAgent))
|
||||
: undefined
|
||||
}
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Prompt bearbeiten",
|
||||
"view": "Prompt anzeigen",
|
||||
"duplicate": "Prompt duplizieren",
|
||||
"delete": "Prompt löschen"
|
||||
"delete": "Prompt löschen",
|
||||
"deleteFailed": "Dieser Prompt konnte nicht gelöscht werden.",
|
||||
"saveFailed": "Dieser Prompt konnte nicht gespeichert werden.",
|
||||
"editConflict": "Jemand anderes hat diesen Prompt geändert. Öffne ihn erneut, um dessen Version zu sehen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -437,7 +440,15 @@
|
||||
"editChunk": "Chunk bearbeiten",
|
||||
"editChunkDescription": "{{file}} · Chunk {{n}} · {{tokens}} Tokens",
|
||||
"previousChunk": "Vorheriger Chunk",
|
||||
"nextChunk": "Nächster Chunk"
|
||||
"nextChunk": "Nächster Chunk",
|
||||
"viewConfig": "Quelleneinstellungen ansehen",
|
||||
"errors": {
|
||||
"forbidden": "Dazu hast du für diese Quelle keine Berechtigung.",
|
||||
"delete": "Die Quelle konnte nicht gelöscht werden.",
|
||||
"sync": "Die Quelle konnte nicht synchronisiert werden.",
|
||||
"syncFrequency": "Die Synchronisierungshäufigkeit konnte nicht geändert werden.",
|
||||
"reingest": "Die Neuaufnahme konnte nicht gestartet werden."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytik",
|
||||
@@ -729,7 +740,184 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "über {{team}}",
|
||||
"removeAccess": "Zugriff entfernen",
|
||||
"access": "Zugriff"
|
||||
"access": "Zugriff",
|
||||
"showAll": "Alle {{count}} anzeigen",
|
||||
"andMore": "und {{count}} weitere",
|
||||
"back": "Zurück",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
|
||||
"searchAccess": "Personen und Teams suchen…",
|
||||
"filterLabel": "Personen mit Zugriff filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"teams": "Teams",
|
||||
"people": "Personen",
|
||||
"editors": "Bearbeiter"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Zugriff konnte nicht geändert werden.",
|
||||
"accessSettings": {
|
||||
"title": "Zugriffseinstellungen",
|
||||
"saveError": "Die Zugriffseinstellung konnte nicht gespeichert werden.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Den Agenten für alle löschen."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Bearbeiter dürfen Zugangsdaten verwalten",
|
||||
"description": "API-Schlüssel, Webhook und öffentlicher Link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Betrachter sehen Protokolle",
|
||||
"description": "Unterhaltungen und Analysen dieses Agenten."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Die Quelle für alle löschen."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Betrachter sehen Einstellungen",
|
||||
"description": "Chunking-, Retriever- und Sync-Einstellungen, nur lesend."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Bearbeiter dürfen Anmeldedaten und Verbindung ändern",
|
||||
"description": "Sie ersetzen gespeicherte Geheimnisse; niemand kann sie auslesen."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Betrachter dürfen es in eigenen Agenten nutzen",
|
||||
"description": "Es läuft mit deinen Anmeldedaten."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Betrachter dürfen duplizieren",
|
||||
"description": "Eine eigene Kopie zum Bearbeiten anlegen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Bearbeiter können ihn ändern, einschließlich Protokollen, Zeitplänen und Zugangsdaten.",
|
||||
"agentNoAccessDetails": "Bearbeiter können ihn ändern, einschließlich Protokollen und Zeitplänen, aber nicht die Zugangsdaten.",
|
||||
"source": "Bearbeiter können Chunks und Dateien bearbeiten, synchronisieren und Einstellungen ändern.",
|
||||
"tool": "Bearbeiter können Aktionen ändern und Anmeldedaten ersetzen, aber keine Geheimnisse lesen.",
|
||||
"toolNoCredentials": "Bearbeiter können Aktionen ändern, aber keine Anmeldedaten.",
|
||||
"prompt": "Bearbeiter können den Text ändern.",
|
||||
"noShareNoDelete": "Sie können es weder teilen noch löschen.",
|
||||
"shareOnly": "Sie können es auch teilen, aber nicht löschen.",
|
||||
"deleteOnly": "Sie können es auch löschen, aber nicht teilen.",
|
||||
"shareAndDelete": "Sie können es auch teilen und löschen.",
|
||||
"noShare": "Sie können es weder teilen noch löschen.",
|
||||
"share": "Sie können es auch teilen, aber nicht löschen."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Betrachter: damit chatten und ihn anheften",
|
||||
"editors": "Bearbeiter: bearbeiten, veröffentlichen, Protokolle sehen und Zeitpläne verwalten"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Betrachter: durchsuchen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: Chunks und Dateien bearbeiten, synchronisieren, Einstellungen ändern"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Betrachter: in den Agenten des Eigentümers nutzen",
|
||||
"editors": "Bearbeiter: Aktionen und Freigaben ändern"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Betrachter: lesen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: den Text ändern"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Bearbeiter können es teilen",
|
||||
"off": "Bearbeiter können es nicht teilen"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Bearbeiter können es löschen",
|
||||
"off": "Bearbeiter können es nicht löschen"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Bearbeiter verwalten API-Schlüssel, Webhook und öffentlichen Link",
|
||||
"off": "Bearbeiter verwalten weder API-Schlüssel noch Webhook oder öffentlichen Link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Betrachter sehen Protokolle",
|
||||
"off": "Betrachter sehen keine Protokolle"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Betrachter sehen die Einstellungen",
|
||||
"off": "Betrachter sehen die Einstellungen nicht"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Bearbeiter können Anmeldedaten ersetzen",
|
||||
"off": "Bearbeiter können Anmeldedaten nicht ändern"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Betrachter können es in eigenen Agenten nutzen",
|
||||
"off": "Betrachter können es nicht in eigenen Agenten nutzen"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Betrachter können es duplizieren",
|
||||
"off": "Betrachter können es nicht duplizieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Bearbeiter",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Geteilte Ressourcen filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"agent": "Agenten",
|
||||
"source": "Quellen",
|
||||
"tool": "Werkzeuge",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Geteilte suchen…",
|
||||
"noMatches": "Keine Treffer."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Schließen",
|
||||
"subtitle": "{{type}} · Eigentümer: {{owner}}",
|
||||
"open": "{{type}} öffnen",
|
||||
"manageSharing": "Freigabe verwalten",
|
||||
"owner": "Eigentümer",
|
||||
"shared": "Geteilt",
|
||||
"sharedOnBy": "{{date}} von {{name}}",
|
||||
"yourAccess": "Dein Zugriff",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Eigentümer",
|
||||
"editor": "Bearbeiter",
|
||||
"viewer": "Betrachter",
|
||||
"none": "Kein Zugriff"
|
||||
},
|
||||
"accessIn": "Zugriff in {{team}}",
|
||||
"everyone": "Alle in {{team}}",
|
||||
"teamGrant": "Ganzes Team",
|
||||
"memberGrant": "Nur diese Person",
|
||||
"removeGrant": "Zugriff entfernen",
|
||||
"otherTeamsHint": "Auch mit anderen Teams geteilt? Diese Freigaben verwaltest du unter „Freigabe verwalten“.",
|
||||
"whatPeopleCanDo": "Was Personen hier dürfen",
|
||||
"capabilitiesHint": "Aus den Zugriffseinstellungen des Eigentümers. Hier nur lesend."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +991,6 @@
|
||||
"addServer": "MCP-Server hinzufügen",
|
||||
"editServer": "Server bearbeiten",
|
||||
"reconnectServer": "Server erneut verbinden",
|
||||
"reenterCredentials": "Gib deine Zugangsdaten erneut ein, um die Verbindung zu testen und zu aktualisieren.",
|
||||
"serverName": "Servername",
|
||||
"serverUrl": "Server-URL",
|
||||
"headerName": "Header-Name",
|
||||
@@ -848,7 +1035,18 @@
|
||||
"oauthFailed": "OAuth-Prozess fehlgeschlagen oder abgebrochen",
|
||||
"oauthTimeout": "OAuth-Prozess abgelaufen, bitte erneut versuchen",
|
||||
"timeoutRange": "Timeout muss zwischen 1 und 300 Sekunden liegen"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Geteilt von {{owner}} · du bist Bearbeiter",
|
||||
"aTeammate": "einem Teammitglied",
|
||||
"sharedCredentialsNotice": "Gespeicherte Zugangsdaten bleiben verborgen. Was du eingibst, ersetzt sie für alle, die dieses Tool nutzen.",
|
||||
"serverChangedNotice": "Der Server hat sich geändert, daher werden die gespeicherten Zugangsdaten ({{credential}}) gelöscht. Gib sie für den neuen Server ein, um zu speichern.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-Schlüssel",
|
||||
"bearer": "Token",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"savedKeyHint": "Ein Schlüssel ist gespeichert. Leer lassen, um ihn zu behalten (nur solange der Server unverändert ist).",
|
||||
"sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Anmeldung neu verbinden. Du kannst das Tool umbenennen, aber weder Server noch Konto ändern."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} ist erforderlich",
|
||||
@@ -856,7 +1054,14 @@
|
||||
"maxTimeout": "Das maximale Timeout beträgt 300 Sekunden"
|
||||
},
|
||||
"headerValuePlaceholder": "z. B. application/json",
|
||||
"toolIconTitle": "{{name}}-Symbol"
|
||||
"toolIconTitle": "{{name}}-Symbol",
|
||||
"view": "Ansehen",
|
||||
"inMyChats": "In meinen Chats",
|
||||
"useInMyChatsAria": "{{toolName}} in meinen Chats verwenden",
|
||||
"statusUpdateFailed": "Konnte nicht ändern, ob dieses Tool in deinen Chats ist.",
|
||||
"deleteFailed": "Dieses Tool konnte nicht gelöscht werden.",
|
||||
"sharedBy": "Geteilt von {{team}}",
|
||||
"savedSecretPlaceholder": "Gespeichert · neuen Wert eingeben zum Ersetzen"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Geräte",
|
||||
@@ -1353,7 +1558,9 @@
|
||||
"test": "Testen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"resetKey": "Schlüssel zurücksetzen",
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"actionFailed": "Das hat nicht funktioniert. Bitte versuche es erneut.",
|
||||
"apiKeyAfterPublish": "Veröffentliche den Agenten, um seinen API-Schlüssel zu erstellen."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API-Spezifikation importieren",
|
||||
@@ -1774,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1792,7 +1998,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Du kannst diese Richtlinie sehen, aber deine Rolle kann sie nicht ändern."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Richten Sie den Agenten ein und veröffentlichen Sie ihn, um mit ihm zu chatten."
|
||||
@@ -2234,7 +2441,8 @@
|
||||
"classicDescription": "Erstelle einen Standard-KI-Agenten mit einem Modell, Tools und Wissensquellen",
|
||||
"workflowTitle": "Workflow-Agent",
|
||||
"workflowDescription": "Entwirf komplexe mehrstufige Workflows mit verschiedenen Modellen, bedingter Logik und Zustandsverwaltung"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Der Agent konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -96,7 +96,10 @@
|
||||
"edit": "Edit prompt",
|
||||
"view": "View prompt",
|
||||
"duplicate": "Duplicate prompt",
|
||||
"delete": "Delete prompt"
|
||||
"delete": "Delete prompt",
|
||||
"deleteFailed": "Couldn't delete this prompt.",
|
||||
"saveFailed": "Couldn't save this prompt.",
|
||||
"editConflict": "Someone else changed this prompt. Reopen it to see their version."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -442,7 +445,15 @@
|
||||
"editChunk": "Edit chunk",
|
||||
"editChunkDescription": "{{file}} · chunk {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Previous chunk",
|
||||
"nextChunk": "Next chunk"
|
||||
"nextChunk": "Next chunk",
|
||||
"viewConfig": "View source settings",
|
||||
"errors": {
|
||||
"forbidden": "You don't have permission to do that on this source.",
|
||||
"delete": "Couldn't delete the source.",
|
||||
"sync": "Couldn't sync the source.",
|
||||
"syncFrequency": "Couldn't change the sync frequency.",
|
||||
"reingest": "Couldn't start the reingest."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
@@ -735,7 +746,184 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "via {{team}}",
|
||||
"removeAccess": "Remove access",
|
||||
"access": "Access"
|
||||
"access": "Access",
|
||||
"showAll": "Show all {{count}}",
|
||||
"andMore": "and {{count}} more",
|
||||
"back": "Back",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
|
||||
"searchAccess": "Search people and teams…",
|
||||
"filterLabel": "Filter people with access",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"teams": "Teams",
|
||||
"people": "People",
|
||||
"editors": "Editors"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Could not change access.",
|
||||
"accessSettings": {
|
||||
"title": "Access settings",
|
||||
"saveError": "Could not save the access setting.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the agent for everyone."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Editors can manage access details",
|
||||
"description": "API key, webhook and public link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Viewers can see logs",
|
||||
"description": "Conversations and analytics for this agent."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the source for everyone."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Viewers can see settings",
|
||||
"description": "Chunking, retriever and sync settings, read only."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Editors can change credentials and connection",
|
||||
"description": "They replace saved secrets; nobody can read them back."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Viewers can use it in their own agents",
|
||||
"description": "It runs with your credentials."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Viewers can duplicate",
|
||||
"description": "Make their own copy to edit."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Editors can change it, including logs, schedules and access details.",
|
||||
"agentNoAccessDetails": "Editors can change it, including logs and schedules, but not access details.",
|
||||
"source": "Editors can edit chunks and files, sync and change settings.",
|
||||
"tool": "Editors can change actions and replace credentials, but can’t read secrets.",
|
||||
"toolNoCredentials": "Editors can change actions, but not credentials.",
|
||||
"prompt": "Editors can change the text.",
|
||||
"noShareNoDelete": "They can’t share or delete it.",
|
||||
"shareOnly": "They can also share it, but can’t delete it.",
|
||||
"deleteOnly": "They can also delete it, but can’t share it.",
|
||||
"shareAndDelete": "They can also share and delete it.",
|
||||
"noShare": "They can’t share or delete it.",
|
||||
"share": "They can also share it, but can’t delete it."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Viewers: chat with it and pin it",
|
||||
"editors": "Editors: edit it, publish it, see logs and manage schedules"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Viewers: browse, search and use it in their agents",
|
||||
"editors": "Editors: edit chunks and files, sync, change settings"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Viewers: use it inside the owner’s agents",
|
||||
"editors": "Editors: change actions and approvals"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Viewers: read it and use it in their agents",
|
||||
"editors": "Editors: change the text"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Editors can share it",
|
||||
"off": "Editors can’t share it"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Editors can delete it",
|
||||
"off": "Editors can’t delete it"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Editors can manage the API key, webhook and public link",
|
||||
"off": "Editors can’t manage the API key, webhook or public link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Viewers can see logs",
|
||||
"off": "Viewers can’t see logs"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Viewers can see its settings",
|
||||
"off": "Viewers can’t see its settings"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Editors can replace credentials",
|
||||
"off": "Editors can’t change credentials"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Viewers can use it in their own agents",
|
||||
"off": "Viewers can’t use it in their own agents"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Viewers can duplicate it",
|
||||
"off": "Viewers can’t duplicate it"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Editor",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filter shared resources",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"agent": "Agents",
|
||||
"source": "Sources",
|
||||
"tool": "Tools",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Search shared…",
|
||||
"noMatches": "Nothing matches."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Close",
|
||||
"subtitle": "{{type}} · owned by {{owner}}",
|
||||
"open": "Open {{type}}",
|
||||
"manageSharing": "Manage sharing",
|
||||
"owner": "Owner",
|
||||
"shared": "Shared",
|
||||
"sharedOnBy": "{{date}} by {{name}}",
|
||||
"yourAccess": "Your access",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Owner",
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer",
|
||||
"none": "No access"
|
||||
},
|
||||
"accessIn": "Access in {{team}}",
|
||||
"everyone": "Everyone in {{team}}",
|
||||
"teamGrant": "Whole team",
|
||||
"memberGrant": "Only this person",
|
||||
"removeGrant": "Remove access",
|
||||
"otherTeamsHint": "Shared with other teams too? Those grants are managed in “Manage sharing”.",
|
||||
"whatPeopleCanDo": "What people here can do",
|
||||
"capabilitiesHint": "From the owner’s access settings. Read-only here."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -809,7 +997,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconnect Server",
|
||||
"reenterCredentials": "Re-enter your credentials to test and update the connection.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -854,7 +1041,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Shared by {{owner}} · you're an editor",
|
||||
"aTeammate": "a teammate",
|
||||
"sharedCredentialsNotice": "Saved credentials stay hidden. Anything you enter replaces them for everyone who uses this tool.",
|
||||
"serverChangedNotice": "The server changed, so the saved {{credential}} will be cleared. Enter it for the new server to save.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API key",
|
||||
"bearer": "token",
|
||||
"password": "password"
|
||||
},
|
||||
"savedKeyHint": "A key is saved. Leave empty to keep it (only while the server is unchanged).",
|
||||
"sharedOAuthOwnerOnly": "Only the owner can reconnect its sign-in, so you can rename it but not change its server or account."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} is required",
|
||||
@@ -862,7 +1060,14 @@
|
||||
"maxTimeout": "Maximum timeout is 300 seconds"
|
||||
},
|
||||
"headerValuePlaceholder": "e.g., application/json",
|
||||
"toolIconTitle": "{{name}} icon"
|
||||
"toolIconTitle": "{{name}} icon",
|
||||
"view": "View",
|
||||
"inMyChats": "In my chats",
|
||||
"useInMyChatsAria": "Use {{toolName}} in my chats",
|
||||
"statusUpdateFailed": "Couldn't change whether this tool is in your chats.",
|
||||
"deleteFailed": "Couldn't delete this tool.",
|
||||
"sharedBy": "Shared by {{team}}",
|
||||
"savedSecretPlaceholder": "Saved · enter a new value to replace"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Devices",
|
||||
@@ -1359,7 +1564,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Reset key",
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone."
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone.",
|
||||
"actionFailed": "That didn't work. Please try again.",
|
||||
"apiKeyAfterPublish": "Publish the agent to create its API key."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Import API Specification",
|
||||
@@ -1792,7 +1999,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1810,7 +2016,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "You can see this policy, but your role can't change it."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Set up the agent, then publish it to chat with it."
|
||||
@@ -2266,7 +2473,8 @@
|
||||
"classicDescription": "Create a standard AI agent with a single model, tools, and knowledge sources",
|
||||
"workflowTitle": "Workflow Agent",
|
||||
"workflowDescription": "Design complex multi-step workflows with different models, conditional logic, and state management"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Could not delete the agent. Please try again."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Editar prompt",
|
||||
"view": "Ver prompt",
|
||||
"duplicate": "Duplicar prompt",
|
||||
"delete": "Eliminar prompt"
|
||||
"delete": "Eliminar prompt",
|
||||
"deleteFailed": "No se pudo eliminar este prompt.",
|
||||
"saveFailed": "No se pudo guardar este prompt.",
|
||||
"editConflict": "Otra persona cambió este prompt. Vuelve a abrirlo para ver su versión."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -437,7 +440,15 @@
|
||||
"editChunk": "Editar fragmento",
|
||||
"editChunkDescription": "{{file}} · fragmento {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Fragmento anterior",
|
||||
"nextChunk": "Fragmento siguiente"
|
||||
"nextChunk": "Fragmento siguiente",
|
||||
"viewConfig": "Ver ajustes de la fuente",
|
||||
"errors": {
|
||||
"forbidden": "No tienes permiso para hacer eso en esta fuente.",
|
||||
"delete": "No se pudo eliminar la fuente.",
|
||||
"sync": "No se pudo sincronizar la fuente.",
|
||||
"syncFrequency": "No se pudo cambiar la frecuencia de sincronización.",
|
||||
"reingest": "No se pudo iniciar la reingesta."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analítica",
|
||||
@@ -729,7 +740,184 @@
|
||||
"teamLabel": "Equipo",
|
||||
"viaTeam": "vía {{team}}",
|
||||
"removeAccess": "Quitar acceso",
|
||||
"access": "Acceso"
|
||||
"access": "Acceso",
|
||||
"showAll": "Ver los {{count}}",
|
||||
"andMore": "y {{count}} más",
|
||||
"back": "Atrás",
|
||||
"allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
|
||||
"searchAccess": "Buscar personas y equipos…",
|
||||
"filterLabel": "Filtrar personas con acceso",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"teams": "Equipos",
|
||||
"people": "Personas",
|
||||
"editors": "Editores"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "No se pudo cambiar el acceso.",
|
||||
"accessSettings": {
|
||||
"title": "Ajustes de acceso",
|
||||
"saveError": "No se pudo guardar el ajuste de acceso.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar el agente para todos."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Los editores pueden gestionar los datos de acceso",
|
||||
"description": "Clave de API, webhook y enlace público."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Los lectores pueden ver los registros",
|
||||
"description": "Conversaciones y analíticas de este agente."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar la fuente para todos."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Los lectores pueden ver los ajustes",
|
||||
"description": "Ajustes de fragmentación, recuperador y sincronización, solo lectura."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Los editores pueden cambiar credenciales y conexión",
|
||||
"description": "Sustituyen los secretos guardados; nadie puede volver a leerlos."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Los lectores pueden usarla en sus propios agentes",
|
||||
"description": "Se ejecuta con tus credenciales."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Los lectores pueden duplicarlo",
|
||||
"description": "Hacer su propia copia para editarla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Los editores pueden cambiarlo, incluidos los registros, las programaciones y los datos de acceso.",
|
||||
"agentNoAccessDetails": "Los editores pueden cambiarlo, incluidos los registros y las programaciones, pero no los datos de acceso.",
|
||||
"source": "Los editores pueden editar fragmentos y archivos, sincronizar y cambiar ajustes.",
|
||||
"tool": "Los editores pueden cambiar acciones y sustituir credenciales, pero no pueden leer secretos.",
|
||||
"toolNoCredentials": "Los editores pueden cambiar acciones, pero no las credenciales.",
|
||||
"prompt": "Los editores pueden cambiar el texto.",
|
||||
"noShareNoDelete": "No pueden compartirlo ni eliminarlo.",
|
||||
"shareOnly": "También pueden compartirlo, pero no eliminarlo.",
|
||||
"deleteOnly": "También pueden eliminarlo, pero no compartirlo.",
|
||||
"shareAndDelete": "También pueden compartirlo y eliminarlo.",
|
||||
"noShare": "No pueden compartirlo ni eliminarlo.",
|
||||
"share": "También pueden compartirlo, pero no eliminarlo."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Lectores: chatear con él y fijarlo",
|
||||
"editors": "Editores: editarlo, publicarlo, ver registros y gestionar programaciones"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Lectores: explorar, buscar y usarla en sus agentes",
|
||||
"editors": "Editores: editar fragmentos y archivos, sincronizar, cambiar ajustes"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Lectores: usarla dentro de los agentes del propietario",
|
||||
"editors": "Editores: cambiar acciones y aprobaciones"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Lectores: leerlo y usarlo en sus agentes",
|
||||
"editors": "Editores: cambiar el texto"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Los editores pueden compartirlo",
|
||||
"off": "Los editores no pueden compartirlo"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Los editores pueden eliminarlo",
|
||||
"off": "Los editores no pueden eliminarlo"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Los editores pueden gestionar la clave de API, el webhook y el enlace público",
|
||||
"off": "Los editores no pueden gestionar la clave de API, el webhook ni el enlace público"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Los lectores pueden ver los registros",
|
||||
"off": "Los lectores no pueden ver los registros"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Los lectores pueden ver sus ajustes",
|
||||
"off": "Los lectores no pueden ver sus ajustes"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Los editores pueden sustituir credenciales",
|
||||
"off": "Los editores no pueden cambiar credenciales"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Los lectores pueden usarla en sus propios agentes",
|
||||
"off": "Los lectores no pueden usarla en sus propios agentes"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Los lectores pueden duplicarlo",
|
||||
"off": "Los lectores no pueden duplicarlo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Editor",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filtrar recursos compartidos",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"agent": "Agentes",
|
||||
"source": "Fuentes",
|
||||
"tool": "Herramientas",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Buscar compartidos…",
|
||||
"noMatches": "No hay coincidencias."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Cerrar",
|
||||
"subtitle": "{{type}} · propiedad de {{owner}}",
|
||||
"open": "Abrir {{type}}",
|
||||
"manageSharing": "Gestionar uso compartido",
|
||||
"owner": "Propietario",
|
||||
"shared": "Compartido",
|
||||
"sharedOnBy": "{{date}} por {{name}}",
|
||||
"yourAccess": "Tu acceso",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Propietario",
|
||||
"editor": "Editor",
|
||||
"viewer": "Lector",
|
||||
"none": "Sin acceso"
|
||||
},
|
||||
"accessIn": "Acceso en {{team}}",
|
||||
"everyone": "Todos en {{team}}",
|
||||
"teamGrant": "Todo el equipo",
|
||||
"memberGrant": "Solo esta persona",
|
||||
"removeGrant": "Quitar acceso",
|
||||
"otherTeamsHint": "¿También compartido con otros equipos? Esos accesos se gestionan en «Gestionar uso compartido».",
|
||||
"whatPeopleCanDo": "Qué pueden hacer aquí",
|
||||
"capabilitiesHint": "Según los ajustes de acceso del propietario. Solo lectura aquí."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +991,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconectar servidor",
|
||||
"reenterCredentials": "Vuelve a introducir tus credenciales para probar y actualizar la conexión.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -848,7 +1035,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Compartido por {{owner}} · eres editor",
|
||||
"aTeammate": "un compañero",
|
||||
"sharedCredentialsNotice": "Las credenciales guardadas permanecen ocultas. Lo que introduzcas las reemplaza para todos los que usan esta herramienta.",
|
||||
"serverChangedNotice": "El servidor cambió, así que se borrarán las credenciales guardadas ({{credential}}). Introdúcelas para el nuevo servidor para guardar.",
|
||||
"credentialNames": {
|
||||
"apiKey": "clave de API",
|
||||
"bearer": "token",
|
||||
"password": "contraseña"
|
||||
},
|
||||
"savedKeyHint": "Hay una clave guardada. Déjalo vacío para conservarla (solo mientras el servidor no cambie).",
|
||||
"sharedOAuthOwnerOnly": "Solo el propietario puede volver a conectar su inicio de sesión, así que puedes cambiarle el nombre, pero no su servidor ni su cuenta."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} es obligatorio",
|
||||
@@ -856,7 +1054,14 @@
|
||||
"maxTimeout": "El tiempo de espera máximo es de 300 segundos"
|
||||
},
|
||||
"headerValuePlaceholder": "p. ej., application/json",
|
||||
"toolIconTitle": "Icono de {{name}}"
|
||||
"toolIconTitle": "Icono de {{name}}",
|
||||
"view": "Ver",
|
||||
"inMyChats": "En mis chats",
|
||||
"useInMyChatsAria": "Usar {{toolName}} en mis chats",
|
||||
"statusUpdateFailed": "No se pudo cambiar si esta herramienta está en tus chats.",
|
||||
"deleteFailed": "No se pudo eliminar esta herramienta.",
|
||||
"sharedBy": "Compartido por {{team}}",
|
||||
"savedSecretPlaceholder": "Guardado · introduce un valor nuevo para reemplazarlo"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Dispositivos",
|
||||
@@ -1353,7 +1558,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Restablecer clave",
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer."
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer.",
|
||||
"actionFailed": "No funcionó. Inténtalo de nuevo.",
|
||||
"apiKeyAfterPublish": "Publica el agente para crear su clave de API."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Importar especificación de API",
|
||||
@@ -1774,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1792,7 +1998,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Puedes ver esta política, pero tu rol no puede cambiarla."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Configura el agente y publícalo para chatear con él."
|
||||
@@ -2234,7 +2441,8 @@
|
||||
"classicDescription": "Crea un agente de IA estándar con un solo modelo, herramientas y fuentes de conocimiento",
|
||||
"workflowTitle": "Agente de flujo de trabajo",
|
||||
"workflowDescription": "Diseña flujos de trabajo complejos de varios pasos con distintos modelos, lógica condicional y gestión de estado"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "No se pudo eliminar el agente. Inténtalo de nuevo."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "プロンプトを編集",
|
||||
"view": "プロンプトを表示",
|
||||
"duplicate": "プロンプトを複製",
|
||||
"delete": "プロンプトを削除"
|
||||
"delete": "プロンプトを削除",
|
||||
"deleteFailed": "このプロンプトを削除できませんでした。",
|
||||
"saveFailed": "このプロンプトを保存できませんでした。",
|
||||
"editConflict": "他のユーザーがこのプロンプトを変更しました。開き直して最新の内容を確認してください。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "チャンクを編集",
|
||||
"editChunkDescription": "{{file}} · チャンク {{n}} · {{tokens}} トークン",
|
||||
"previousChunk": "前のチャンク",
|
||||
"nextChunk": "次のチャンク"
|
||||
"nextChunk": "次のチャンク",
|
||||
"viewConfig": "ソース設定を表示",
|
||||
"errors": {
|
||||
"forbidden": "このソースでその操作を行う権限がありません。",
|
||||
"delete": "ソースを削除できませんでした。",
|
||||
"sync": "ソースを同期できませんでした。",
|
||||
"syncFrequency": "同期頻度を変更できませんでした。",
|
||||
"reingest": "再取り込みを開始できませんでした。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "チーム",
|
||||
"viaTeam": "{{team}}経由",
|
||||
"removeAccess": "アクセス権を削除",
|
||||
"access": "アクセス"
|
||||
"access": "アクセス",
|
||||
"showAll": "すべて表示({{count}})",
|
||||
"andMore": "ほか {{count}} 件",
|
||||
"back": "戻る",
|
||||
"allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
|
||||
"searchAccess": "ユーザーとチームを検索…",
|
||||
"filterLabel": "アクセス権を持つユーザーを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"teams": "チーム",
|
||||
"people": "ユーザー",
|
||||
"editors": "編集者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "アクセス権を変更できませんでした。",
|
||||
"accessSettings": {
|
||||
"title": "アクセス設定",
|
||||
"saveError": "アクセス設定を保存できませんでした。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してエージェントを削除します。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編集者がアクセス情報を管理できる",
|
||||
"description": "API キー、Webhook、公開リンク。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "閲覧者がログを見られる",
|
||||
"description": "このエージェントの会話と分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してソースを削除します。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "閲覧者が設定を見られる",
|
||||
"description": "チャンク分割、リトリーバー、同期の設定(読み取り専用)。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編集者が認証情報と接続を変更できる",
|
||||
"description": "保存済みのシークレットを置き換えます。誰も読み戻せません。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "閲覧者が自分のエージェントで使える",
|
||||
"description": "あなたの認証情報で実行されます。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "閲覧者が複製できる",
|
||||
"description": "編集用に自分のコピーを作成します。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編集者はログ、スケジュール、アクセス情報を含めて変更できます。",
|
||||
"agentNoAccessDetails": "編集者はログとスケジュールを含めて変更できますが、アクセス情報は変更できません。",
|
||||
"source": "編集者はチャンクとファイルの編集、同期、設定の変更ができます。",
|
||||
"tool": "編集者はアクションの変更と認証情報の置き換えができますが、シークレットは読めません。",
|
||||
"toolNoCredentials": "編集者はアクションを変更できますが、認証情報は変更できません。",
|
||||
"prompt": "編集者はテキストを変更できます。",
|
||||
"noShareNoDelete": "共有と削除はできません。",
|
||||
"shareOnly": "共有もできますが、削除はできません。",
|
||||
"deleteOnly": "削除もできますが、共有はできません。",
|
||||
"shareAndDelete": "共有と削除もできます。",
|
||||
"noShare": "共有と削除はできません。",
|
||||
"share": "共有もできますが、削除はできません。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "閲覧者:チャットとピン留め",
|
||||
"editors": "編集者:編集、公開、ログの閲覧、スケジュールの管理"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "閲覧者:閲覧、検索、自分のエージェントでの利用",
|
||||
"editors": "編集者:チャンクとファイルの編集、同期、設定の変更"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "閲覧者:所有者のエージェント内で利用",
|
||||
"editors": "編集者:アクションと承認の変更"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "閲覧者:閲覧と自分のエージェントでの利用",
|
||||
"editors": "編集者:テキストの変更"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編集者は共有できます",
|
||||
"off": "編集者は共有できません"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編集者は削除できます",
|
||||
"off": "編集者は削除できません"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編集者は API キー、Webhook、公開リンクを管理できます",
|
||||
"off": "編集者は API キー、Webhook、公開リンクを管理できません"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "閲覧者はログを見られます",
|
||||
"off": "閲覧者はログを見られません"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "閲覧者は設定を見られます",
|
||||
"off": "閲覧者は設定を見られません"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編集者は認証情報を置き換えられます",
|
||||
"off": "編集者は認証情報を変更できません"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "閲覧者は自分のエージェントで使えます",
|
||||
"off": "閲覧者は自分のエージェントで使えません"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "閲覧者は複製できます",
|
||||
"off": "閲覧者は複製できません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 編集者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "共有リソースを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"agent": "エージェント",
|
||||
"source": "ソース",
|
||||
"tool": "ツール",
|
||||
"prompt": "プロンプト"
|
||||
},
|
||||
"search": "共有を検索…",
|
||||
"noMatches": "一致するものはありません。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "閉じる",
|
||||
"subtitle": "{{type}} · 所有者: {{owner}}",
|
||||
"open": "{{type}}を開く",
|
||||
"manageSharing": "共有を管理",
|
||||
"owner": "所有者",
|
||||
"shared": "共有日",
|
||||
"sharedOnBy": "{{date}}({{name}})",
|
||||
"yourAccess": "あなたのアクセス権",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "編集者",
|
||||
"viewer": "閲覧者",
|
||||
"none": "アクセス権なし"
|
||||
},
|
||||
"accessIn": "{{team}} でのアクセス権",
|
||||
"everyone": "{{team}} の全員",
|
||||
"teamGrant": "チーム全体",
|
||||
"memberGrant": "この人のみ",
|
||||
"removeGrant": "アクセス権を削除",
|
||||
"otherTeamsHint": "ほかのチームとも共有していますか?それらは「共有を管理」で管理します。",
|
||||
"whatPeopleCanDo": "ここでできること",
|
||||
"capabilitiesHint": "所有者のアクセス設定に基づきます。ここでは読み取り専用です。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "サーバーに再接続",
|
||||
"reenterCredentials": "接続をテストして更新するには、認証情報を再入力してください。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "{{owner}} が共有 · あなたは編集者です",
|
||||
"aTeammate": "チームメンバー",
|
||||
"sharedCredentialsNotice": "保存済みの認証情報は表示されません。入力した内容は、このツールを使うすべての人の認証情報を置き換えます。",
|
||||
"serverChangedNotice": "サーバーが変更されたため、保存済みの{{credential}}は消去されます。保存するには新しいサーバー用に入力してください。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API キー",
|
||||
"bearer": "トークン",
|
||||
"password": "パスワード"
|
||||
},
|
||||
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
|
||||
"sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}は必須です",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "タイムアウトの上限は300秒です"
|
||||
},
|
||||
"headerValuePlaceholder": "例: application/json",
|
||||
"toolIconTitle": "{{name}}のアイコン"
|
||||
"toolIconTitle": "{{name}}のアイコン",
|
||||
"view": "表示",
|
||||
"inMyChats": "自分のチャットで使用",
|
||||
"useInMyChatsAria": "{{toolName}} を自分のチャットで使用",
|
||||
"statusUpdateFailed": "このツールをチャットで使うかどうかを変更できませんでした。",
|
||||
"deleteFailed": "このツールを削除できませんでした。",
|
||||
"sharedBy": "{{team}} が共有",
|
||||
"savedSecretPlaceholder": "保存済み · 置き換えるには新しい値を入力"
|
||||
},
|
||||
"devices": {
|
||||
"label": "デバイス",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "キーをリセット",
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。"
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
|
||||
"actionFailed": "うまくいきませんでした。もう一度お試しください。",
|
||||
"apiKeyAfterPublish": "APIキーを作成するには、エージェントを公開してください。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API仕様のインポート",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "このポリシーは表示できますが、あなたのロールでは変更できません。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "エージェントを設定し、公開するとチャットできます。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "単一のモデル、ツール、ナレッジソースを持つ標準的な AI エージェントを作成します",
|
||||
"workflowTitle": "ワークフローエージェント",
|
||||
"workflowDescription": "複数のモデル、条件ロジック、状態管理を使った複雑なマルチステップのワークフローを設計します"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "エージェントを削除できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Редактировать промпт",
|
||||
"view": "Просмотреть промпт",
|
||||
"duplicate": "Дублировать промпт",
|
||||
"delete": "Удалить промпт"
|
||||
"delete": "Удалить промпт",
|
||||
"deleteFailed": "Не удалось удалить этот промпт.",
|
||||
"saveFailed": "Не удалось сохранить этот промпт.",
|
||||
"editConflict": "Кто-то другой изменил этот промпт. Откройте его заново, чтобы увидеть новую версию."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -465,7 +468,15 @@
|
||||
"editChunk": "Редактировать фрагмент",
|
||||
"editChunkDescription": "{{file}} · фрагмент {{n}} · токенов: {{tokens}}",
|
||||
"previousChunk": "Предыдущий фрагмент",
|
||||
"nextChunk": "Следующий фрагмент"
|
||||
"nextChunk": "Следующий фрагмент",
|
||||
"viewConfig": "Посмотреть настройки источника",
|
||||
"errors": {
|
||||
"forbidden": "У вас нет прав на это действие с этим источником.",
|
||||
"delete": "Не удалось удалить источник.",
|
||||
"sync": "Не удалось синхронизировать источник.",
|
||||
"syncFrequency": "Не удалось изменить частоту синхронизации.",
|
||||
"reingest": "Не удалось запустить повторную загрузку."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Аналитика",
|
||||
@@ -771,7 +782,184 @@
|
||||
"teamLabel": "Команда",
|
||||
"viaTeam": "через {{team}}",
|
||||
"removeAccess": "Убрать доступ",
|
||||
"access": "Доступ"
|
||||
"access": "Доступ",
|
||||
"showAll": "Показать все ({{count}})",
|
||||
"andMore": "и ещё {{count}}",
|
||||
"back": "Назад",
|
||||
"allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
|
||||
"searchAccess": "Поиск людей и команд…",
|
||||
"filterLabel": "Фильтр пользователей с доступом",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"teams": "Команды",
|
||||
"people": "Люди",
|
||||
"editors": "Редакторы"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Не удалось изменить доступ.",
|
||||
"accessSettings": {
|
||||
"title": "Настройки доступа",
|
||||
"saveError": "Не удалось сохранить настройку доступа.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить агента для всех."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Редакторы управляют данными доступа",
|
||||
"description": "API-ключ, вебхук и публичная ссылка."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Читатели видят журналы",
|
||||
"description": "Диалоги и аналитика этого агента."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить источник для всех."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Читатели видят настройки",
|
||||
"description": "Настройки разбиения, ретривера и синхронизации, только чтение."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Редакторы могут менять учётные данные и подключение",
|
||||
"description": "Они заменяют сохранённые секреты; прочитать их не может никто."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Читатели могут использовать его в своих агентах",
|
||||
"description": "Он работает с вашими учётными данными."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Читатели могут создавать копию",
|
||||
"description": "Сделать свою копию для редактирования."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Редакторы могут менять его, включая журналы, расписания и данные доступа.",
|
||||
"agentNoAccessDetails": "Редакторы могут менять его, включая журналы и расписания, но не данные доступа.",
|
||||
"source": "Редакторы могут править фрагменты и файлы, синхронизировать и менять настройки.",
|
||||
"tool": "Редакторы могут менять действия и заменять учётные данные, но не читать секреты.",
|
||||
"toolNoCredentials": "Редакторы могут менять действия, но не учётные данные.",
|
||||
"prompt": "Редакторы могут менять текст.",
|
||||
"noShareNoDelete": "Делиться и удалять они не могут.",
|
||||
"shareOnly": "Они также могут делиться, но не удалять.",
|
||||
"deleteOnly": "Они также могут удалять, но не делиться.",
|
||||
"shareAndDelete": "Они также могут делиться и удалять.",
|
||||
"noShare": "Делиться и удалять они не могут.",
|
||||
"share": "Они также могут делиться, но не удалять."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Читатели: общаться с ним и закреплять его",
|
||||
"editors": "Редакторы: править, публиковать, смотреть журналы и управлять расписаниями"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Читатели: просматривать, искать и использовать в своих агентах",
|
||||
"editors": "Редакторы: править фрагменты и файлы, синхронизировать, менять настройки"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Читатели: использовать в агентах владельца",
|
||||
"editors": "Редакторы: менять действия и подтверждения"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Читатели: читать и использовать в своих агентах",
|
||||
"editors": "Редакторы: менять текст"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Редакторы могут делиться",
|
||||
"off": "Редакторы не могут делиться"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Редакторы могут удалять",
|
||||
"off": "Редакторы не могут удалять"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Редакторы управляют API-ключом, вебхуком и публичной ссылкой",
|
||||
"off": "Редакторы не управляют API-ключом, вебхуком и публичной ссылкой"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Читатели видят журналы",
|
||||
"off": "Читатели не видят журналы"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Читатели видят настройки",
|
||||
"off": "Читатели не видят настройки"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Редакторы могут заменять учётные данные",
|
||||
"off": "Редакторы не могут менять учётные данные"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Читатели могут использовать его в своих агентах",
|
||||
"off": "Читатели не могут использовать его в своих агентах"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Читатели могут создавать копию",
|
||||
"off": "Читатели не могут создавать копию"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} ред.",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Фильтр общих ресурсов",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"agent": "Агенты",
|
||||
"source": "Источники",
|
||||
"tool": "Инструменты",
|
||||
"prompt": "Промпты"
|
||||
},
|
||||
"search": "Поиск в общих…",
|
||||
"noMatches": "Ничего не найдено."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Закрыть",
|
||||
"subtitle": "{{type}} · владелец: {{owner}}",
|
||||
"open": "Открыть: {{type}}",
|
||||
"manageSharing": "Управлять доступом",
|
||||
"owner": "Владелец",
|
||||
"shared": "Открыт",
|
||||
"sharedOnBy": "{{date}}, {{name}}",
|
||||
"yourAccess": "Ваш доступ",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Владелец",
|
||||
"editor": "Редактор",
|
||||
"viewer": "Читатель",
|
||||
"none": "Нет доступа"
|
||||
},
|
||||
"accessIn": "Доступ в {{team}}",
|
||||
"everyone": "Все в {{team}}",
|
||||
"teamGrant": "Вся команда",
|
||||
"memberGrant": "Только этот человек",
|
||||
"removeGrant": "Убрать доступ",
|
||||
"otherTeamsHint": "Доступ есть и у других команд? Им управляют в «Управлять доступом».",
|
||||
"whatPeopleCanDo": "Что здесь можно делать",
|
||||
"capabilitiesHint": "Из настроек доступа владельца. Здесь только для чтения."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -845,7 +1033,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Переподключить сервер",
|
||||
"reenterCredentials": "Введите учетные данные ещё раз, чтобы проверить и обновить подключение.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -890,7 +1077,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Предоставил(а) {{owner}} · вы редактор",
|
||||
"aTeammate": "участник команды",
|
||||
"sharedCredentialsNotice": "Сохранённые учётные данные скрыты. Введённые вами данные заменят их для всех, кто пользуется этим инструментом.",
|
||||
"serverChangedNotice": "Сервер изменился, поэтому сохранённый {{credential}} будет удалён. Введите его для нового сервера, чтобы сохранить.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-ключ",
|
||||
"bearer": "токен",
|
||||
"password": "пароль"
|
||||
},
|
||||
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
|
||||
"sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "Поле «{{field}}» обязательно",
|
||||
@@ -898,7 +1096,14 @@
|
||||
"maxTimeout": "Максимальный тайм-аут — 300 секунд"
|
||||
},
|
||||
"headerValuePlaceholder": "например, application/json",
|
||||
"toolIconTitle": "Значок {{name}}"
|
||||
"toolIconTitle": "Значок {{name}}",
|
||||
"view": "Просмотр",
|
||||
"inMyChats": "В моих чатах",
|
||||
"useInMyChatsAria": "Использовать {{toolName}} в моих чатах",
|
||||
"statusUpdateFailed": "Не удалось изменить, используется ли этот инструмент в ваших чатах.",
|
||||
"deleteFailed": "Не удалось удалить этот инструмент.",
|
||||
"sharedBy": "Предоставлено: {{team}}",
|
||||
"savedSecretPlaceholder": "Сохранено · введите новое значение для замены"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Устройства",
|
||||
@@ -1409,7 +1614,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Сбросить ключ",
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить."
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
|
||||
"actionFailed": "Не получилось. Попробуйте ещё раз.",
|
||||
"apiKeyAfterPublish": "Опубликуйте агента, чтобы создать его API-ключ."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Импорт спецификации API",
|
||||
@@ -1838,7 +2045,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1856,7 +2062,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Вы видите эту политику, но ваша роль не позволяет её изменить."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
|
||||
@@ -2310,7 +2517,8 @@
|
||||
"classicDescription": "Создайте стандартного ИИ-агента с одной моделью, инструментами и источниками знаний",
|
||||
"workflowTitle": "Агент рабочего процесса",
|
||||
"workflowDescription": "Создавайте сложные многошаговые рабочие процессы с разными моделями, условной логикой и управлением состоянием"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Не удалось удалить агента. Попробуйте ещё раз."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "編輯提示詞",
|
||||
"view": "檢視提示詞",
|
||||
"duplicate": "複製提示詞",
|
||||
"delete": "刪除提示詞"
|
||||
"delete": "刪除提示詞",
|
||||
"deleteFailed": "無法刪除此提示詞。",
|
||||
"saveFailed": "無法儲存此提示詞。",
|
||||
"editConflict": "其他人已變更此提示詞。請重新開啟以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "編輯文本塊",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 個文本塊 · {{tokens}} Token",
|
||||
"previousChunk": "上一個文本塊",
|
||||
"nextChunk": "下一個文本塊"
|
||||
"nextChunk": "下一個文本塊",
|
||||
"viewConfig": "檢視來源設定",
|
||||
"errors": {
|
||||
"forbidden": "你沒有權限對此來源執行該操作。",
|
||||
"delete": "無法刪除來源。",
|
||||
"sync": "無法同步來源。",
|
||||
"syncFrequency": "無法變更同步頻率。",
|
||||
"reingest": "無法開始重新匯入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "團隊",
|
||||
"viaTeam": "透過 {{team}}",
|
||||
"removeAccess": "移除存取權",
|
||||
"access": "存取權"
|
||||
"access": "存取權",
|
||||
"showAll": "顯示全部 {{count}} 個",
|
||||
"andMore": "還有 {{count}} 個",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
|
||||
"searchAccess": "搜尋人員和團隊…",
|
||||
"filterLabel": "篩選具有存取權的人員",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "團隊",
|
||||
"people": "人員",
|
||||
"editors": "編輯者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "無法變更存取權。",
|
||||
"accessSettings": {
|
||||
"title": "存取設定",
|
||||
"saveError": "無法儲存存取設定。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編輯者可以管理存取詳細資料",
|
||||
"description": "API 金鑰、Webhook 和公開連結。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "檢視者可以查看記錄",
|
||||
"description": "此代理的對話和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此來源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "檢視者可以查看設定",
|
||||
"description": "分塊、檢索器和同步設定,唯讀。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編輯者可以變更憑證和連線",
|
||||
"description": "他們會取代已儲存的密鑰;任何人都無法讀回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "檢視者可以在自己的代理中使用",
|
||||
"description": "它會以你的憑證執行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "檢視者可以複製",
|
||||
"description": "建立自己的副本來編輯。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編輯者可以修改它,包括記錄、排程和存取詳細資料。",
|
||||
"agentNoAccessDetails": "編輯者可以修改它,包括記錄和排程,但不能修改存取詳細資料。",
|
||||
"source": "編輯者可以編輯分塊和檔案、同步並變更設定。",
|
||||
"tool": "編輯者可以變更動作並取代憑證,但無法讀取密鑰。",
|
||||
"toolNoCredentials": "編輯者可以變更動作,但不能變更憑證。",
|
||||
"prompt": "編輯者可以修改文字。",
|
||||
"noShareNoDelete": "他們不能共用或刪除它。",
|
||||
"shareOnly": "他們也可以共用它,但不能刪除。",
|
||||
"deleteOnly": "他們也可以刪除它,但不能共用。",
|
||||
"shareAndDelete": "他們也可以共用和刪除它。",
|
||||
"noShare": "他們不能共用或刪除它。",
|
||||
"share": "他們也可以共用它,但不能刪除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "檢視者:與其對話並釘選",
|
||||
"editors": "編輯者:編輯、發布、查看記錄和管理排程"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "檢視者:瀏覽、搜尋並在自己的代理中使用",
|
||||
"editors": "編輯者:編輯分塊和檔案、同步、變更設定"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "檢視者:在擁有者的代理中使用",
|
||||
"editors": "編輯者:變更動作和核准"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "檢視者:閱讀並在自己的代理中使用",
|
||||
"editors": "編輯者:修改文字"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編輯者可以共用",
|
||||
"off": "編輯者不能共用"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編輯者可以刪除",
|
||||
"off": "編輯者不能刪除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編輯者可以管理 API 金鑰、Webhook 和公開連結",
|
||||
"off": "編輯者不能管理 API 金鑰、Webhook 或公開連結"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "檢視者可以查看記錄",
|
||||
"off": "檢視者不能查看記錄"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "檢視者可以查看設定",
|
||||
"off": "檢視者不能查看設定"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編輯者可以取代憑證",
|
||||
"off": "編輯者不能變更憑證"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "檢視者可以在自己的代理中使用",
|
||||
"off": "檢視者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "檢視者可以複製",
|
||||
"off": "檢視者不能複製"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 編輯者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "篩選共用資源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "來源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示"
|
||||
},
|
||||
"search": "搜尋共用…",
|
||||
"noMatches": "沒有相符項目。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "關閉",
|
||||
"subtitle": "{{type}} · 擁有者:{{owner}}",
|
||||
"open": "開啟{{type}}",
|
||||
"manageSharing": "管理共用",
|
||||
"owner": "擁有者",
|
||||
"shared": "共用時間",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的存取權",
|
||||
"yourAccessLevel": {
|
||||
"owner": "擁有者",
|
||||
"editor": "編輯者",
|
||||
"viewer": "檢視者",
|
||||
"none": "無存取權"
|
||||
},
|
||||
"accessIn": "{{team}} 中的存取權",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整個團隊",
|
||||
"memberGrant": "僅此人",
|
||||
"removeGrant": "移除存取權",
|
||||
"otherTeamsHint": "也與其他團隊共用了嗎?這些授權在「管理共用」中管理。",
|
||||
"whatPeopleCanDo": "這裡的人員可以做什麼",
|
||||
"capabilitiesHint": "來自擁有者的存取設定。此處為唯讀。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新連線伺服器",
|
||||
"reenterCredentials": "重新輸入您的憑證以測試並更新連線。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 分享 · 你是編輯者",
|
||||
"aTeammate": "一位隊友",
|
||||
"sharedCredentialsNotice": "已儲存的憑證不會顯示。你輸入的內容將為所有使用此工具的人取代它們。",
|
||||
"serverChangedNotice": "伺服器已變更,因此已儲存的{{credential}}將被清除。請為新伺服器輸入後再儲存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 金鑰",
|
||||
"bearer": "權杖",
|
||||
"password": "密碼"
|
||||
},
|
||||
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
|
||||
"sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}為必填項",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "逾時時間最長為 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 圖示"
|
||||
"toolIconTitle": "{{name}} 圖示",
|
||||
"view": "檢視",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "無法變更此工具是否用於你的聊天。",
|
||||
"deleteFailed": "無法刪除此工具。",
|
||||
"sharedBy": "由 {{team}} 分享",
|
||||
"savedSecretPlaceholder": "已儲存 · 輸入新值以取代"
|
||||
},
|
||||
"devices": {
|
||||
"label": "裝置",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重設金鑰",
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。"
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
|
||||
"actionFailed": "操作未成功,請再試一次。",
|
||||
"apiKeyAfterPublish": "發布此代理後即可建立其 API 金鑰。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "匯入 API 規格",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "你可以檢視此政策,但你的角色無法變更它。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "設定好代理後發佈,即可與它對話。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "建立一個使用單一模型、工具和知識來源的標準 AI 代理",
|
||||
"workflowTitle": "工作流程代理",
|
||||
"workflowDescription": "設計包含不同模型、條件邏輯和狀態管理的複雜多步驟工作流程"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "無法刪除此代理,請再試一次。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "编辑提示词",
|
||||
"view": "查看提示词",
|
||||
"duplicate": "复制提示词",
|
||||
"delete": "删除提示词"
|
||||
"delete": "删除提示词",
|
||||
"deleteFailed": "无法删除此提示词。",
|
||||
"saveFailed": "无法保存此提示词。",
|
||||
"editConflict": "其他人已更改此提示词。请重新打开以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "编辑文本块",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 个文本块 · {{tokens}} 个令牌",
|
||||
"previousChunk": "上一个文本块",
|
||||
"nextChunk": "下一个文本块"
|
||||
"nextChunk": "下一个文本块",
|
||||
"viewConfig": "查看来源设置",
|
||||
"errors": {
|
||||
"forbidden": "你无权对此来源执行该操作。",
|
||||
"delete": "无法删除来源。",
|
||||
"sync": "无法同步来源。",
|
||||
"syncFrequency": "无法更改同步频率。",
|
||||
"reingest": "无法开始重新导入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "团队",
|
||||
"viaTeam": "通过 {{team}}",
|
||||
"removeAccess": "移除访问权限",
|
||||
"access": "访问权限"
|
||||
"access": "访问权限",
|
||||
"showAll": "显示全部 {{count}} 个",
|
||||
"andMore": "还有 {{count}} 个",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
|
||||
"searchAccess": "搜索人员和团队…",
|
||||
"filterLabel": "筛选有权访问的人员",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "团队",
|
||||
"people": "人员",
|
||||
"editors": "编辑者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "无法更改访问权限。",
|
||||
"accessSettings": {
|
||||
"title": "访问设置",
|
||||
"saveError": "无法保存访问设置。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "编辑者可以管理访问详情",
|
||||
"description": "API 密钥、Webhook 和公开链接。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "查看者可以查看日志",
|
||||
"description": "该代理的对话和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该来源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "查看者可以查看设置",
|
||||
"description": "分块、检索器和同步设置,只读。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "编辑者可以更改凭据和连接",
|
||||
"description": "他们替换已保存的密钥;任何人都无法读回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "查看者可以在自己的代理中使用",
|
||||
"description": "它使用你的凭据运行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "查看者可以复制",
|
||||
"description": "创建自己的副本进行编辑。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "编辑者可以修改它,包括日志、计划和访问详情。",
|
||||
"agentNoAccessDetails": "编辑者可以修改它,包括日志和计划,但不能修改访问详情。",
|
||||
"source": "编辑者可以编辑分块和文件、同步并更改设置。",
|
||||
"tool": "编辑者可以更改操作并替换凭据,但无法读取密钥。",
|
||||
"toolNoCredentials": "编辑者可以更改操作,但不能更改凭据。",
|
||||
"prompt": "编辑者可以修改文本。",
|
||||
"noShareNoDelete": "他们不能共享或删除它。",
|
||||
"shareOnly": "他们还可以共享它,但不能删除。",
|
||||
"deleteOnly": "他们还可以删除它,但不能共享。",
|
||||
"shareAndDelete": "他们还可以共享和删除它。",
|
||||
"noShare": "他们不能共享或删除它。",
|
||||
"share": "他们还可以共享它,但不能删除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "查看者:与其对话并置顶",
|
||||
"editors": "编辑者:编辑、发布、查看日志和管理计划"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "查看者:浏览、搜索并在自己的代理中使用",
|
||||
"editors": "编辑者:编辑分块和文件、同步、更改设置"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "查看者:在所有者的代理中使用",
|
||||
"editors": "编辑者:更改操作和审批"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "查看者:阅读并在自己的代理中使用",
|
||||
"editors": "编辑者:修改文本"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "编辑者可以共享",
|
||||
"off": "编辑者不能共享"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "编辑者可以删除",
|
||||
"off": "编辑者不能删除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "编辑者可以管理 API 密钥、Webhook 和公开链接",
|
||||
"off": "编辑者不能管理 API 密钥、Webhook 或公开链接"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "查看者可以查看日志",
|
||||
"off": "查看者不能查看日志"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "查看者可以查看设置",
|
||||
"off": "查看者不能查看设置"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "编辑者可以替换凭据",
|
||||
"off": "编辑者不能更改凭据"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "查看者可以在自己的代理中使用",
|
||||
"off": "查看者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "查看者可以复制",
|
||||
"off": "查看者不能复制"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 编辑者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "筛选共享资源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "来源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示词"
|
||||
},
|
||||
"search": "搜索共享…",
|
||||
"noMatches": "没有匹配项。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "关闭",
|
||||
"subtitle": "{{type}} · 所有者:{{owner}}",
|
||||
"open": "打开{{type}}",
|
||||
"manageSharing": "管理共享",
|
||||
"owner": "所有者",
|
||||
"shared": "共享时间",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的访问权限",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "编辑者",
|
||||
"viewer": "查看者",
|
||||
"none": "无访问权限"
|
||||
},
|
||||
"accessIn": "{{team}} 中的访问权限",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整个团队",
|
||||
"memberGrant": "仅此人",
|
||||
"removeGrant": "移除访问权限",
|
||||
"otherTeamsHint": "也与其他团队共享了?这些授权在“管理共享”中管理。",
|
||||
"whatPeopleCanDo": "这里的人员可以做什么",
|
||||
"capabilitiesHint": "来自所有者的访问设置。此处只读。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新连接服务器",
|
||||
"reenterCredentials": "重新输入您的凭据以测试并更新连接。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 共享 · 你是编辑者",
|
||||
"aTeammate": "一位队友",
|
||||
"sharedCredentialsNotice": "已保存的凭据不会显示。你输入的内容将为所有使用此工具的人替换它们。",
|
||||
"serverChangedNotice": "服务器已更改,因此已保存的{{credential}}将被清除。请为新服务器输入后再保存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 密钥",
|
||||
"bearer": "令牌",
|
||||
"password": "密码"
|
||||
},
|
||||
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
|
||||
"sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}为必填项",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "超时时间最长为 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 图标"
|
||||
"toolIconTitle": "{{name}} 图标",
|
||||
"view": "查看",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "无法更改此工具是否用于你的聊天。",
|
||||
"deleteFailed": "无法删除此工具。",
|
||||
"sharedBy": "由 {{team}} 共享",
|
||||
"savedSecretPlaceholder": "已保存 · 输入新值以替换"
|
||||
},
|
||||
"devices": {
|
||||
"label": "设备",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重置密钥",
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。"
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
|
||||
"actionFailed": "操作未成功,请重试。",
|
||||
"apiKeyAfterPublish": "发布该代理后即可创建其 API 密钥。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "导入 API 规范",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "你可以查看此策略,但你的角色无法更改它。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "设置好智能体后发布,即可与它对话。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "创建一个使用单一模型、工具和知识来源的标准 AI 智能体",
|
||||
"workflowTitle": "工作流智能体",
|
||||
"workflowDescription": "设计包含不同模型、条件逻辑和状态管理的复杂多步骤工作流"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "无法删除该代理,请重试。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
shareAgent: vi.fn(),
|
||||
getAgentWebhook: vi.fn(),
|
||||
regenerateAgentKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({ preference: { token: null } }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: mocks }));
|
||||
|
||||
vi.mock('./ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-key" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import type { Agent } from '../agents/types';
|
||||
import AgentDetailsModal from './AgentDetailsModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentDetailsModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
Object.values(mocks).forEach((m) => m.mockReset());
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (agent: Partial<Agent>) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentDetailsModal
|
||||
agent={{ id: 'a1', name: 'Bot', ...agent } as Agent}
|
||||
mode="edit"
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// The three sections each have a Generate button until they hold a value.
|
||||
const generateButtons = () =>
|
||||
Array.from(document.querySelectorAll('button')).filter(
|
||||
(b) => b.textContent === 'modals.agentDetails.generate',
|
||||
);
|
||||
|
||||
it('generates a missing API key through the reset confirmation', async () => {
|
||||
mocks.regenerateAgentKey.mockReturnValue(respond({ key: 'new-key' }));
|
||||
await render({ status: 'published' });
|
||||
const [, apiKey] = generateButtons();
|
||||
await act(async () => apiKey.click());
|
||||
await act(async () =>
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-key"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.regenerateAgentKey).toHaveBeenCalledWith('a1', null);
|
||||
expect(document.body.textContent).toContain('new-key');
|
||||
});
|
||||
|
||||
it('asks a draft to publish first instead of offering a key', async () => {
|
||||
await render({ status: 'draft' });
|
||||
expect(generateButtons()).toHaveLength(2);
|
||||
expect(document.body.textContent).toContain(
|
||||
'modals.agentDetails.apiKeyAfterPublish',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a refused public link in an alert', async () => {
|
||||
mocks.shareAgent.mockReturnValue(
|
||||
respond({ success: false, message: 'Not allowed' }, false),
|
||||
);
|
||||
await render({ status: 'published' });
|
||||
await act(async () => generateButtons()[0].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Not allowed');
|
||||
});
|
||||
|
||||
it('shows a failed webhook in an alert with a fallback message', async () => {
|
||||
mocks.getAgentWebhook.mockReturnValue(respond({}, false));
|
||||
await render({ status: 'published' });
|
||||
const buttons = generateButtons();
|
||||
await act(async () => buttons[buttons.length - 1].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('modals.agentDetails.actionFailed');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { envVar } from '@/env';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { CircleX, ExternalLink } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
|
||||
import { Agent } from '../agents/types';
|
||||
import userService from '../api/services/userService';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
@@ -16,6 +17,18 @@ import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
const baseURL = envVar('VITE_BASE_URL');
|
||||
|
||||
/** The backend's `message` on a refused call, else null. */
|
||||
const errorMessage = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return typeof body?.message === 'string' && body.message.trim()
|
||||
? body.message
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type AgentDetailsModalProps = {
|
||||
agent: Agent;
|
||||
mode: 'new' | 'edit' | 'draft';
|
||||
@@ -41,6 +54,8 @@ export default function AgentDetailsModal({
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [resetKeyConfirmState, setResetKeyConfirmState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
// A failed generate or reset, shown in the modal until the next attempt.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
publicLink: false,
|
||||
apiKey: false,
|
||||
@@ -54,49 +69,61 @@ export default function AgentDetailsModal({
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: state }));
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = async () => {
|
||||
setLoading('publicLink', true);
|
||||
const response = await userService.shareAgent(
|
||||
{ id: agent.id ?? '', shared: true },
|
||||
token,
|
||||
);
|
||||
if (!response.ok) {
|
||||
setLoading('publicLink', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setSharedToken(data.shared_token);
|
||||
setLoading('publicLink', false);
|
||||
};
|
||||
|
||||
const handleGenerateWebhook = async () => {
|
||||
setLoading('webhook', true);
|
||||
const response = await userService.getAgentWebhook(agent.id ?? '', token);
|
||||
if (!response.ok) {
|
||||
setLoading('webhook', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setWebhookUrl(data.webhook_url);
|
||||
setLoading('webhook', false);
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async () => {
|
||||
setLoading('apiKey', true);
|
||||
/**
|
||||
* Runs one of the modal's calls, showing its failure in the Alert.
|
||||
*
|
||||
* @param key Which button shows the spinner.
|
||||
* @param request The call; resolves to the response.
|
||||
* @param onSuccess Receives the parsed body of a successful response.
|
||||
*/
|
||||
const run = async (
|
||||
key: 'publicLink' | 'apiKey' | 'webhook',
|
||||
request: () => Promise<Response>,
|
||||
onSuccess: (data: Record<string, string>) => void,
|
||||
) => {
|
||||
setLoading(key, true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await userService.regenerateAgentKey(
|
||||
agent.id ?? '',
|
||||
token,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
const response = await request();
|
||||
if (!response.ok) {
|
||||
setError(
|
||||
(await errorMessage(response)) ??
|
||||
t('modals.agentDetails.actionFailed'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSuccess(await response.json());
|
||||
} catch {
|
||||
setError(t('modals.agentDetails.actionFailed'));
|
||||
} finally {
|
||||
setLoading('apiKey', false);
|
||||
setLoading(key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = () =>
|
||||
run(
|
||||
'publicLink',
|
||||
() => userService.shareAgent({ id: agent.id ?? '', shared: true }, token),
|
||||
(data) => setSharedToken(data.shared_token),
|
||||
);
|
||||
|
||||
const handleGenerateWebhook = () =>
|
||||
run(
|
||||
'webhook',
|
||||
() => userService.getAgentWebhook(agent.id ?? '', token),
|
||||
(data) => setWebhookUrl(data.webhook_url),
|
||||
);
|
||||
|
||||
const handleRegenerateKey = () =>
|
||||
run(
|
||||
'apiKey',
|
||||
() => userService.regenerateAgentKey(agent.id ?? '', token),
|
||||
(data) => {
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSharedToken(agent.shared_token ?? null);
|
||||
setApiKey(agent.key ?? null);
|
||||
@@ -111,6 +138,12 @@ export default function AgentDetailsModal({
|
||||
size="md"
|
||||
>
|
||||
<div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-6">
|
||||
<CircleX />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mt-8 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -216,8 +249,21 @@ export default function AgentDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : agent.status === 'draft' ? (
|
||||
// A draft has no key yet: the first one is minted on publish.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('modals.agentDetails.apiKeyAfterPublish')}
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" variant="outline-primary" shape="pill">
|
||||
// No key shown on a published agent: minting one replaces
|
||||
// any key it has, so it goes through the reset confirmation.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
shape="pill"
|
||||
onClick={() => setResetKeyConfirmState('ACTIVE')}
|
||||
loading={loadingStates.apiKey}
|
||||
>
|
||||
{t('modals.agentDetails.generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ notifications: { recentEvents: [] }, preference: {} }),
|
||||
}));
|
||||
vi.mock('../preferences/preferenceSlice', () => ({
|
||||
selectToken: () => 'token',
|
||||
}));
|
||||
vi.mock('../notifications/notificationsSlice', () => ({
|
||||
selectRecentEvents: (state: { notifications: { recentEvents: unknown[] } }) =>
|
||||
state.notifications.recentEvents,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts || typeof opts !== 'object') return key;
|
||||
const { defaultValue: _d, interpolation: _i, ...rest } = opts;
|
||||
void _d;
|
||||
void _i;
|
||||
return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const testMCPConnection = vi.fn();
|
||||
const saveMCPServer = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
testMCPConnection: (...args: unknown[]) => testMCPConnection(...args),
|
||||
saveMCPServer: (...args: unknown[]) => saveMCPServer(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import MCPServerModal from './MCPServerModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const server = {
|
||||
id: 'tool-1',
|
||||
displayName: 'Carrier Rates MCP',
|
||||
server_url: 'https://mcp.dana-tools.dev/sse',
|
||||
auth_type: 'api_key',
|
||||
timeout: 30,
|
||||
oauth_scopes: '',
|
||||
has_encrypted_credentials: true,
|
||||
access: 'owner',
|
||||
owner_label: null as string | null,
|
||||
};
|
||||
|
||||
const json = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('MCPServerModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
testMCPConnection.mockReset();
|
||||
saveMCPServer.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (overrides: Partial<typeof server> = {}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MCPServerModal
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => {}}
|
||||
server={{ ...server, ...overrides }}
|
||||
onServerSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const text = () => document.body.textContent ?? '';
|
||||
const button = (label: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((b) => b.textContent === label)!;
|
||||
const typeInto = async (input: HTMLInputElement, value: string) => {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
const urlInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="https://example.com/mcp"]',
|
||||
)!;
|
||||
|
||||
it('tells an editor whose tool it is and that their entry replaces it for everyone', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena Fischer' });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}',
|
||||
);
|
||||
const info = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="alert"]'),
|
||||
).find((a) =>
|
||||
a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'),
|
||||
);
|
||||
expect(info?.dataset.variant ?? info?.className).toMatch(/info/);
|
||||
});
|
||||
|
||||
it('falls back to "a teammate" without an owner label', async () => {
|
||||
await render({ access: 'editor', owner_label: null });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"settings.tools.mcp.aTeammate"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no sharing notices on the caller's own tool", async () => {
|
||||
await render();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedByEditor');
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedCredentialsNotice');
|
||||
});
|
||||
|
||||
it('hints that a saved key is kept when left empty', async () => {
|
||||
await render();
|
||||
expect(text()).toContain('settings.tools.mcp.savedKeyHint');
|
||||
});
|
||||
|
||||
it('tests with the saved key while the server is unchanged', async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tool-1' }),
|
||||
'token',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and requires a new key when the server host changes', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena' });
|
||||
await typeInto(urlInput(), 'https://evil.example.com/sse');
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.serverChangedNotice:{"credential":"settings.tools.mcp.credentialNames.apiKey"}',
|
||||
);
|
||||
expect(text()).not.toContain('settings.tools.mcp.savedKeyHint');
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).not.toHaveBeenCalled();
|
||||
expect(text()).toContain('settings.tools.mcp.errors.apiKeyRequired');
|
||||
});
|
||||
|
||||
it('keeps the saved key for a path-only change on the same host', async () => {
|
||||
await render();
|
||||
await typeInto(urlInput(), 'https://mcp.dana-tools.dev/v2/sse');
|
||||
expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice');
|
||||
});
|
||||
|
||||
it("shows the server's save error in the error Alert", async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
saveMCPServer.mockReturnValue(
|
||||
json({ success: false, message: 'Invalid server URL' }, false, 400),
|
||||
);
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
await act(async () => button('settings.tools.mcp.save').click());
|
||||
expect(text()).toContain('Invalid server URL');
|
||||
});
|
||||
|
||||
it('lets an editor rename an OAuth tool without reconnecting it', async () => {
|
||||
saveMCPServer.mockReturnValue(json({ success: true }));
|
||||
await render({
|
||||
access: 'editor',
|
||||
owner_label: 'Lena',
|
||||
auth_type: 'oauth',
|
||||
has_encrypted_credentials: false,
|
||||
});
|
||||
expect(text()).toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
expect(urlInput().disabled).toBe(true);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeUndefined();
|
||||
const save = button('settings.tools.mcp.save');
|
||||
expect(save.disabled).toBe(false);
|
||||
await act(async () => save.click());
|
||||
expect(saveMCPServer).toHaveBeenCalledTimes(1);
|
||||
expect(testMCPConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps OAuth reconnect for the owner', async () => {
|
||||
await render({ auth_type: 'oauth', has_encrypted_credentials: false });
|
||||
expect(urlInput().disabled).toBe(false);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeDefined();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -30,6 +30,22 @@ interface MCPServerModalProps {
|
||||
onServerSaved: () => void;
|
||||
}
|
||||
|
||||
/** The host of a URL, or '' while it doesn't parse. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url.trim()).host.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// The saved secret each auth type keeps, named for the host-change notice.
|
||||
const SECRET_FIELDS: Record<string, { field: string; nameKey: string }> = {
|
||||
api_key: { field: 'api_key', nameKey: 'apiKey' },
|
||||
bearer: { field: 'bearer_token', nameKey: 'bearer' },
|
||||
basic: { field: 'password', nameKey: 'password' },
|
||||
};
|
||||
|
||||
export default function MCPServerModal({
|
||||
modalState,
|
||||
setModalState,
|
||||
@@ -95,6 +111,24 @@ export default function MCPServerModal({
|
||||
const [oauthCompleted, setOAuthCompleted] = useState(false);
|
||||
const [saveActive, setSaveActive] = useState(false);
|
||||
|
||||
// A tool shared with the caller (an editor reconnecting the owner's
|
||||
// server): its saved secrets stay hidden and a new entry replaces them.
|
||||
const isShared = !!server?.access && server.access !== 'owner';
|
||||
// Only the owner can re-run an OAuth sign-in (the tokens are theirs), so a
|
||||
// teammate may rename an OAuth tool but not change its server or account.
|
||||
const oauthOwnerOnly = isShared && server?.auth_type === 'oauth';
|
||||
const savedSecret = SECRET_FIELDS[formData.auth_type];
|
||||
const hasSavedSecret =
|
||||
!!server?.has_encrypted_credentials &&
|
||||
!!savedSecret &&
|
||||
formData.auth_type === server?.auth_type;
|
||||
// The server clears the saved secret when the host changes, so the key
|
||||
// can't be pointed at another server.
|
||||
const serverChanged =
|
||||
hasSavedSecret &&
|
||||
hostOf(formData.server_url) !== hostOf(server?.server_url || '');
|
||||
const keepSavedSecret = hasSavedSecret && !serverChanged;
|
||||
|
||||
const cleanupOAuthListener = useCallback(() => {
|
||||
setOauthTaskId(null);
|
||||
handledEventIdsRef.current = new Set();
|
||||
@@ -167,17 +201,17 @@ export default function MCPServerModal({
|
||||
|
||||
const authFieldChecks: { [key: string]: () => void } = {
|
||||
api_key: () => {
|
||||
if (!formData.api_key.trim())
|
||||
if (!formData.api_key.trim() && !keepSavedSecret)
|
||||
newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired');
|
||||
},
|
||||
bearer: () => {
|
||||
if (!formData.bearer_token.trim())
|
||||
if (!formData.bearer_token.trim() && !keepSavedSecret)
|
||||
newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired');
|
||||
},
|
||||
basic: () => {
|
||||
if (!formData.username.trim())
|
||||
newErrors.username = t('settings.tools.mcp.errors.usernameRequired');
|
||||
if (!formData.password.trim())
|
||||
if (!formData.password.trim() && !keepSavedSecret)
|
||||
newErrors.password = t('settings.tools.mcp.errors.passwordRequired');
|
||||
},
|
||||
};
|
||||
@@ -299,6 +333,7 @@ export default function MCPServerModal({
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: t('settings.tools.mcp.oauthPopupBlocked', {
|
||||
interpolation: { escapeValue: false },
|
||||
defaultValue:
|
||||
'Popup blocked by browser. Click below to authorize:',
|
||||
}),
|
||||
@@ -369,7 +404,11 @@ export default function MCPServerModal({
|
||||
setOAuthCompleted(false);
|
||||
try {
|
||||
const config = buildToolConfig();
|
||||
const response = await userService.testMCPConnection({ config }, token);
|
||||
// The id lets the server test with the saved secret left empty.
|
||||
const response = await userService.testMCPConnection(
|
||||
{ config, ...(server?.id && { id: server.id }) },
|
||||
token,
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (
|
||||
@@ -441,7 +480,10 @@ export default function MCPServerModal({
|
||||
resetForm();
|
||||
} else {
|
||||
setErrors({
|
||||
general: result.error || t('settings.tools.mcp.errors.saveFailed'),
|
||||
general:
|
||||
result.message ||
|
||||
result.error ||
|
||||
t('settings.tools.mcp.errors.saveFailed'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -460,6 +502,11 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.apiKey')}
|
||||
required
|
||||
error={errors.api_key}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -486,6 +533,9 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.bearer')}
|
||||
required
|
||||
error={errors.bearer_token}
|
||||
hint={
|
||||
keepSavedSecret ? t('settings.tools.mcp.savedKeyHint') : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -516,6 +566,11 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.password')}
|
||||
required
|
||||
error={errors.password}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -536,6 +591,7 @@ export default function MCPServerModal({
|
||||
handleInputChange('oauth_scopes', e.target.value)
|
||||
}
|
||||
placeholder="read, write"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
@@ -560,21 +616,31 @@ export default function MCPServerModal({
|
||||
})
|
||||
: t('settings.tools.mcp.addServer')
|
||||
}
|
||||
description={
|
||||
isShared
|
||||
? t('settings.tools.mcp.sharedByEditor', {
|
||||
interpolation: { escapeValue: false },
|
||||
owner: server.owner_label || t('settings.tools.mcp.aTeammate'),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
footer={
|
||||
<ModalActions
|
||||
footerStart={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
oauthOwnerOnly ? undefined : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
cancelLabel={t('settings.tools.mcp.cancel')}
|
||||
onCancel={() => {
|
||||
@@ -584,23 +650,21 @@ export default function MCPServerModal({
|
||||
submitLabel={t('settings.tools.mcp.save')}
|
||||
onSubmit={handleSave}
|
||||
pending={loading}
|
||||
disabled={!saveActive}
|
||||
disabled={!saveActive && !oauthOwnerOnly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{server?.has_encrypted_credentials &&
|
||||
formData.auth_type !== 'oauth' && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.reenterCredentials', {
|
||||
defaultValue:
|
||||
'Re-enter your credentials to test and update the connection.',
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isShared && (
|
||||
<Alert variant="info">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.sharedCredentialsNotice')}
|
||||
{oauthOwnerOnly &&
|
||||
` ${t('settings.tools.mcp.sharedOAuthOwnerOnly')}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<FormField
|
||||
label={t('settings.tools.mcp.serverName')}
|
||||
required
|
||||
@@ -624,13 +688,28 @@ export default function MCPServerModal({
|
||||
value={formData.server_url}
|
||||
onChange={(e) => handleInputChange('server_url', e.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{serverChanged && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.serverChangedNotice', {
|
||||
interpolation: { escapeValue: false },
|
||||
credential: t(
|
||||
`settings.tools.mcp.credentialNames.${savedSecret.nameKey}`,
|
||||
),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField label={t('settings.tools.mcp.authType')}>
|
||||
<Select
|
||||
value={formData.auth_type}
|
||||
onValueChange={(v) => handleInputChange('auth_type', v)}
|
||||
disabled={oauthOwnerOnly}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue placeholder={t('settings.tools.mcp.authType')} />
|
||||
|
||||
@@ -85,6 +85,10 @@ export type Doc = {
|
||||
// Access level when shared via a team: 'viewer' (read-only) or 'editor'
|
||||
// (full write). Null/absent for sources the caller owns.
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
// The caller's role and what it allows (sources API); gate UI with
|
||||
// `can(doc, action)` from utils/accessUtils.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
export type GetDocsResponse = {
|
||||
@@ -98,10 +102,16 @@ export type Prompt = {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
// The caller's role and what it allows (prompts API); gate UI with
|
||||
// `can(prompt, action)` from utils/accessUtils. Absent on presets.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type PromptProps = {
|
||||
prompts: { name: string; id: string; type: string }[];
|
||||
prompts: Prompt[];
|
||||
selectedPrompt: { name: string; id: string; type: string };
|
||||
onSelectPrompt: (name: string, id: string, type: string) => void;
|
||||
setPrompts: (prompts: { name: string; id: string; type: string }[]) => void;
|
||||
|
||||
@@ -142,6 +142,48 @@ describe('buildAgentSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs for a draft', () => {
|
||||
it('shows only Overview until the agent is published', () => {
|
||||
const items = getSectionItems(
|
||||
buildAgentSection('a1', 'Bot', false, {
|
||||
access: 'owner',
|
||||
status: 'draft',
|
||||
allowed_actions: ['view', 'view_logs', 'manage_schedules'],
|
||||
}),
|
||||
).map((item) => item.key);
|
||||
expect(items).toEqual(['overview']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs by role', () => {
|
||||
const keys = (actions?: string[]) =>
|
||||
getSectionItems(
|
||||
buildAgentSection(
|
||||
'a1',
|
||||
'Bot',
|
||||
false,
|
||||
actions ? { access: 'editor', allowed_actions: actions } : undefined,
|
||||
),
|
||||
).map((item) => item.key);
|
||||
|
||||
it('shows every tab before the agent has loaded', () => {
|
||||
expect(keys()).toEqual(['overview', 'logs', 'schedules']);
|
||||
});
|
||||
|
||||
it('shows an editor all three tabs', () => {
|
||||
expect(keys(['view', 'view_logs', 'manage_schedules'])).toEqual([
|
||||
'overview',
|
||||
'logs',
|
||||
'schedules',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a viewer no tabs, or Logs when the owner shares them', () => {
|
||||
expect(keys(['pin', 'use'])).toEqual([]);
|
||||
expect(keys(['pin', 'use', 'view_logs'])).toEqual(['logs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('depthOf', () => {
|
||||
it('puts chats, sections and records on their own level', () => {
|
||||
expect(depthOf(null)).toBe(0);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
agentSchedulesPath,
|
||||
agentsFilterPath,
|
||||
} from '../agents/paths';
|
||||
import { type AccessFields } from '../utils/accessUtils';
|
||||
import { canAgent } from '../agents/agentAccess';
|
||||
|
||||
/** A single destination in a section's vertical nav. */
|
||||
export type SectionItem = {
|
||||
@@ -268,18 +270,54 @@ export const AGENTS_SECTION: Section = {
|
||||
],
|
||||
};
|
||||
|
||||
/** The action each agent tab's page needs. */
|
||||
const TAB_ACTIONS: Record<string, string> = {
|
||||
overview: 'view',
|
||||
logs: 'view_logs',
|
||||
schedules: 'manage_schedules',
|
||||
};
|
||||
|
||||
/**
|
||||
* The nav for a single agent. Built per route rather than declared, because
|
||||
* its title is the agent's name and its paths carry the agent's id.
|
||||
*
|
||||
* `access` is the agent's record once loaded: each tab shows only when the
|
||||
* caller's role allows its page (Overview `view`, Logs `view_logs`,
|
||||
* Schedules `manage_schedules`). Until the record arrives every tab shows,
|
||||
* and the route guard sends a caller who may not open a page back to the
|
||||
* list.
|
||||
*/
|
||||
export function buildAgentSection(
|
||||
agentId: string,
|
||||
agentName: string | undefined,
|
||||
workflow: boolean,
|
||||
access?: (AccessFields & { status?: string }) | null,
|
||||
): Section {
|
||||
const allows = (action: string) => !access || canAgent(access, action);
|
||||
const items: SectionItem[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
path: agentEditPath(agentId, workflow),
|
||||
labelKey: 'agents.pageHeader.tabs.overview',
|
||||
icon: SquarePen,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
path: agentLogsPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.logs',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'schedules',
|
||||
path: agentSchedulesPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.schedules',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
];
|
||||
const visible = items.filter((item) => allows(TAB_ACTIONS[item.key]));
|
||||
return {
|
||||
key: `agent:${agentId}`,
|
||||
rootPath: agentEditPath(agentId, workflow),
|
||||
rootPath: visible[0]?.path ?? agentEditPath(agentId, workflow),
|
||||
titleKey: 'agents.pageHeader.fallbackName',
|
||||
title: agentName?.trim() || undefined,
|
||||
matches: [
|
||||
@@ -289,31 +327,7 @@ export function buildAgentSection(
|
||||
],
|
||||
parentPath: AGENTS_MANAGE_ROOT,
|
||||
parentLabelKey: 'navigation.backToAgents',
|
||||
groups: [
|
||||
{
|
||||
key: 'agent',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
path: agentEditPath(agentId, workflow),
|
||||
labelKey: 'agents.pageHeader.tabs.overview',
|
||||
icon: SquarePen,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
path: agentLogsPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.logs',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'schedules',
|
||||
path: agentSchedulesPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.schedules',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
groups: [{ key: 'agent', items: visible }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,15 +36,20 @@ export function useSectionContext(): {
|
||||
const scoped = matchAgentScopedRoute(pathname);
|
||||
if (!scoped) return getSectionForPath(pathname);
|
||||
|
||||
const name = [
|
||||
const record = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === scoped.agentId)?.name;
|
||||
].find((agent) => agent.id === scoped.agentId);
|
||||
|
||||
// An agent saved moments ago may not be in the store yet; the section
|
||||
// falls back to a generic title until it arrives.
|
||||
return buildAgentSection(scoped.agentId, name, scoped.workflow);
|
||||
return buildAgentSection(
|
||||
scoped.agentId,
|
||||
record?.name,
|
||||
scoped.workflow,
|
||||
record,
|
||||
);
|
||||
}, [pathname, agents, sharedAgents, selectedAgent]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,13 +25,18 @@ export function useSectionResolver(): (pathname: string) => Section | null {
|
||||
const scoped = matchAgentScopedRoute(pathname);
|
||||
if (!scoped) return getSectionForPath(pathname);
|
||||
|
||||
const name = [
|
||||
const record = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === scoped.agentId)?.name;
|
||||
].find((agent) => agent.id === scoped.agentId);
|
||||
|
||||
return buildAgentSection(scoped.agentId, name, scoped.workflow);
|
||||
return buildAgentSection(
|
||||
scoped.agentId,
|
||||
record?.name,
|
||||
scoped.workflow,
|
||||
record,
|
||||
);
|
||||
},
|
||||
[agents, sharedAgents, selectedAgent],
|
||||
);
|
||||
|
||||
@@ -131,4 +131,40 @@ describe('PromptsModal', () => {
|
||||
'modals.prompts.systemVariablesDropdownLabel',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a prompt the caller may not edit read-only', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PromptsModal
|
||||
existingPrompts={[]}
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => undefined}
|
||||
type="EDIT"
|
||||
newPromptName=""
|
||||
setNewPromptName={() => undefined}
|
||||
newPromptContent=""
|
||||
setNewPromptContent={() => undefined}
|
||||
editPromptName="Carrier rates"
|
||||
setEditPromptName={() => undefined}
|
||||
editPromptContent="Summarise {{ source.content }}"
|
||||
setEditPromptContent={() => undefined}
|
||||
currentPromptEdit={{ name: 'Carrier rates', id: 'p1', type: 'team' }}
|
||||
handleEditPrompt={() => undefined}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(document.body.textContent).toContain('modals.prompts.viewPrompt');
|
||||
expect(
|
||||
document.body.querySelector<HTMLTextAreaElement>('textarea')?.readOnly,
|
||||
).toBe(true);
|
||||
expect(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="text"]')
|
||||
?.disabled,
|
||||
).toBe(true);
|
||||
const labels = Array.from(document.body.querySelectorAll('button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
expect(labels).not.toContain('modals.prompts.save');
|
||||
});
|
||||
});
|
||||
@@ -376,13 +376,13 @@ function EditPrompt({
|
||||
setEditPromptName,
|
||||
editPromptContent,
|
||||
setEditPromptContent,
|
||||
currentPromptEdit,
|
||||
isReadOnly,
|
||||
}: {
|
||||
editPromptName: string;
|
||||
setEditPromptName: (name: string) => void;
|
||||
editPromptContent: string;
|
||||
setEditPromptContent: (content: string) => void;
|
||||
currentPromptEdit: { name: string; id: string; type: string };
|
||||
isReadOnly: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const systemVariableOptions = React.useMemo(
|
||||
@@ -390,7 +390,6 @@ function EditPrompt({
|
||||
[t],
|
||||
);
|
||||
const toolVariables = useToolVariables();
|
||||
const isReadOnly = currentPromptEdit.type === 'public';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -468,6 +467,7 @@ export default function PromptsModal({
|
||||
handleEditPrompt,
|
||||
onDuplicate,
|
||||
duplicateSourceName,
|
||||
readOnly = false,
|
||||
}: {
|
||||
existingPrompts: { name: string; id: string; type: string }[];
|
||||
modalState: ActiveState;
|
||||
@@ -491,6 +491,8 @@ export default function PromptsModal({
|
||||
handleEditPrompt?: (id: string, type: string) => void;
|
||||
onDuplicate?: () => void;
|
||||
duplicateSourceName?: string | null;
|
||||
/** Open an EDIT prompt as a view: the caller may not edit it. */
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const disableSave = React.useMemo(() => {
|
||||
if (type === 'EDIT') {
|
||||
@@ -515,7 +517,8 @@ export default function PromptsModal({
|
||||
]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isReadOnly = type === 'EDIT' && currentPromptEdit.type === 'public';
|
||||
const isReadOnly =
|
||||
type === 'EDIT' && (readOnly || currentPromptEdit.type === 'public');
|
||||
const closeModal = () => setModalState('INACTIVE');
|
||||
|
||||
let view;
|
||||
@@ -554,7 +557,7 @@ export default function PromptsModal({
|
||||
setEditPromptName={setEditPromptName}
|
||||
editPromptContent={editPromptContent}
|
||||
setEditPromptContent={setEditPromptContent}
|
||||
currentPromptEdit={currentPromptEdit}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => 'test-token',
|
||||
useDispatch: () => dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: {} }));
|
||||
const deletePrompt = vi.fn();
|
||||
const updatePrompt = vi.fn();
|
||||
const getSinglePrompt = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
deletePrompt: (...args: unknown[]) => deletePrompt(...args),
|
||||
updatePrompt: (...args: unknown[]) => updatePrompt(...args),
|
||||
getSinglePrompt: (...args: unknown[]) => getSinglePrompt(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
const promptsModalProps = vi.fn();
|
||||
vi.mock('../preferences/PromptsModal', () => ({
|
||||
default: (props: unknown) => {
|
||||
promptsModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({ handleSubmit }: { handleSubmit: () => void }) => (
|
||||
<button type="button" data-testid="confirm" onClick={handleSubmit}>
|
||||
confirm
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Prompts from './Prompts';
|
||||
|
||||
@@ -206,4 +229,203 @@ describe('Prompts', () => {
|
||||
expect(action.dataset.slot).toBe('tooltip-trigger');
|
||||
}
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
const json = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
const own = {
|
||||
id: 'own',
|
||||
name: 'Own prompt',
|
||||
type: 'private',
|
||||
access: 'owner' as const,
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'duplicate',
|
||||
'edit',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
],
|
||||
};
|
||||
const editor = {
|
||||
id: 'ed',
|
||||
name: 'Editor prompt',
|
||||
type: 'team',
|
||||
access: 'editor' as const,
|
||||
allowed_actions: ['duplicate', 'edit', 'use'],
|
||||
};
|
||||
const viewer = {
|
||||
id: 'vw',
|
||||
name: 'Viewer prompt',
|
||||
type: 'team',
|
||||
access: 'viewer' as const,
|
||||
allowed_actions: ['duplicate', 'use'],
|
||||
};
|
||||
const viewerNoCopy = {
|
||||
id: 'vn',
|
||||
name: 'Locked prompt',
|
||||
type: 'team',
|
||||
access: 'viewer' as const,
|
||||
allowed_actions: ['use'],
|
||||
};
|
||||
const all = [prompts[0], own, editor, viewer, viewerNoCopy];
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
deletePrompt.mockReset();
|
||||
updatePrompt.mockReset();
|
||||
getSinglePrompt.mockReset();
|
||||
promptsModalProps.mockReset();
|
||||
});
|
||||
|
||||
const openPicker = () =>
|
||||
act(() => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[role="combobox"]',
|
||||
)!;
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const row = (name: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-slot="command-item"]',
|
||||
),
|
||||
).find((item) => item.textContent?.includes(name))!;
|
||||
const actionsOf = (name: string) =>
|
||||
Array.from(row(name).querySelectorAll('button')).map((b) =>
|
||||
b.getAttribute('aria-label'),
|
||||
);
|
||||
const lastModalProps = () =>
|
||||
promptsModalProps.mock.calls.at(-1)![0] as {
|
||||
readOnly?: boolean;
|
||||
handleEditPrompt: (id: string, type: string) => void;
|
||||
onDuplicate?: () => void;
|
||||
};
|
||||
|
||||
it('gives the owner Edit, Duplicate, Share and Delete', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Own prompt')).toEqual([
|
||||
'settings.general.promptActions.edit',
|
||||
'settings.general.promptActions.duplicate',
|
||||
'agents.shareWithTeam',
|
||||
'settings.general.promptActions.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an editor Edit and Duplicate', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Editor prompt')).toEqual([
|
||||
'settings.general.promptActions.edit',
|
||||
'settings.general.promptActions.duplicate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a viewer View, and Duplicate only when allowed', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Viewer prompt')).toEqual([
|
||||
'settings.general.promptActions.view',
|
||||
'settings.general.promptActions.duplicate',
|
||||
]);
|
||||
expect(actionsOf('Locked prompt')).toEqual([
|
||||
'settings.general.promptActions.view',
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a viewer's prompt read-only", async () => {
|
||||
getSinglePrompt.mockReturnValue(json({ content: 'Hello' }));
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(row('Viewer prompt').querySelector('button') as HTMLElement).click();
|
||||
});
|
||||
expect(lastModalProps().readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the row and shows an error when the delete fails', async () => {
|
||||
deletePrompt.mockReturnValue(json({ success: false }, false, 403));
|
||||
const setPrompts = vi.fn();
|
||||
renderPrompts({ prompts: all, selectedPrompt: own, setPrompts });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(
|
||||
row('Own prompt').querySelector(
|
||||
'button[aria-label="settings.general.promptActions.delete"]',
|
||||
) as HTMLElement
|
||||
).click();
|
||||
});
|
||||
await act(async () => {
|
||||
(
|
||||
document.body.querySelector('[data-testid="confirm"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(setPrompts).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
variant: 'destructive',
|
||||
message: 'settings.general.promptActions.deleteFailed',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the row once the delete succeeds', async () => {
|
||||
deletePrompt.mockReturnValue(json({ success: true }));
|
||||
const setPrompts = vi.fn();
|
||||
renderPrompts({ prompts: all, selectedPrompt: own, setPrompts });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(
|
||||
row('Own prompt').querySelector(
|
||||
'button[aria-label="settings.general.promptActions.delete"]',
|
||||
) as HTMLElement
|
||||
).click();
|
||||
});
|
||||
await act(async () => {
|
||||
(
|
||||
document.body.querySelector('[data-testid="confirm"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(setPrompts).toHaveBeenCalledWith(
|
||||
all.filter((p) => p.id !== 'own'),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the loaded updated_at and reports a 409 as an edit conflict', async () => {
|
||||
getSinglePrompt.mockReturnValue(
|
||||
json({ content: 'Hello', updated_at: '2026-09-01T10:00:00Z' }),
|
||||
);
|
||||
updatePrompt.mockReturnValue(
|
||||
json({ success: false, code: 'stale_write' }, false, 409),
|
||||
);
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(row('Editor prompt').querySelector('button') as HTMLElement).click();
|
||||
});
|
||||
expect(lastModalProps().readOnly).toBe(false);
|
||||
await act(async () => lastModalProps().handleEditPrompt('ed', 'team'));
|
||||
expect(updatePrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'ed',
|
||||
expected_updated_at: '2026-09-01T10:00:00Z',
|
||||
}),
|
||||
'test-token',
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
variant: 'destructive',
|
||||
message: 'settings.general.promptActions.editConflict',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronDown, Copy, Eye, Pencil, Trash2, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
@@ -22,12 +22,22 @@ import {
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import { SettingRow } from '../components/ui/setting-row';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, PromptProps } from '../models/misc';
|
||||
import { ActiveState, Prompt, PromptProps } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import PromptsModal from '../preferences/PromptsModal';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Presets (`public`) carry no access fields and are never edited in place.
|
||||
const canEditPrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'edit');
|
||||
const canDeletePrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'delete');
|
||||
const canSharePrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'share');
|
||||
|
||||
type PromptsDropdownProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -62,18 +72,27 @@ export default function Prompts({
|
||||
labelSurface = 'card',
|
||||
}: ExtendedPromptProps) {
|
||||
const token = useSelector(selectToken);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const showError = (message: string) =>
|
||||
dispatch(showActionToast({ variant: 'destructive', message }));
|
||||
const pickerId = React.useId();
|
||||
const titleText = title ? title : t('settings.general.prompt');
|
||||
const [newPromptName, setNewPromptName] = React.useState('');
|
||||
const [newPromptContent, setNewPromptContent] = React.useState('');
|
||||
const [editPromptName, setEditPromptName] = React.useState('');
|
||||
const [editPromptContent, setEditPromptContent] = React.useState('');
|
||||
const [currentPromptEdit, setCurrentPromptEdit] = React.useState({
|
||||
const [currentPromptEdit, setCurrentPromptEdit] = React.useState<Prompt>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
});
|
||||
// The open prompt's version, sent back so a save over someone else's
|
||||
// newer edit is refused (409) instead of overwriting it.
|
||||
const [editPromptUpdatedAt, setEditPromptUpdatedAt] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [editReadOnly, setEditReadOnly] = React.useState(false);
|
||||
const [modalType, setModalType] = React.useState<'ADD' | 'EDIT'>('ADD');
|
||||
const [duplicateSource, setDuplicateSource] = React.useState<string | null>(
|
||||
null,
|
||||
@@ -138,13 +157,15 @@ export default function Prompts({
|
||||
|
||||
const confirmDeletePrompt = () => {
|
||||
if (promptToDelete) {
|
||||
setPrompts(prompts.filter((prompt) => prompt.id !== promptToDelete.id));
|
||||
userService
|
||||
.deletePrompt({ id: promptToDelete.id }, token)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete prompt');
|
||||
}
|
||||
setPrompts(
|
||||
prompts.filter((prompt) => prompt.id !== promptToDelete.id),
|
||||
);
|
||||
// Only change selection if we're deleting the currently selected prompt
|
||||
if (
|
||||
prompts.length > 0 &&
|
||||
@@ -163,6 +184,7 @@ export default function Prompts({
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showError(t('settings.general.promptActions.deleteFailed'));
|
||||
});
|
||||
setPromptToDelete(null);
|
||||
}
|
||||
@@ -176,17 +198,16 @@ export default function Prompts({
|
||||
}
|
||||
const promptContent = await response.json();
|
||||
setEditPromptContent(promptContent.content);
|
||||
setEditPromptUpdatedAt(promptContent.updated_at ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (prompt: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}) => {
|
||||
const openEditModal = (prompt: Prompt) => {
|
||||
setModalType('EDIT');
|
||||
setEditReadOnly(!canEditPrompt(prompt));
|
||||
setEditPromptUpdatedAt(null);
|
||||
setEditPromptName(prompt.name);
|
||||
setEditPromptContent('');
|
||||
handleFetchPromptContent(prompt.id);
|
||||
@@ -244,12 +265,22 @@ export default function Prompts({
|
||||
id: id,
|
||||
name: editPromptName,
|
||||
content: editPromptContent,
|
||||
...(editPromptUpdatedAt && {
|
||||
expected_updated_at: editPromptUpdatedAt,
|
||||
}),
|
||||
},
|
||||
token,
|
||||
)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update prompt');
|
||||
showError(
|
||||
t(
|
||||
response.status === 409
|
||||
? 'settings.general.promptActions.editConflict'
|
||||
: 'settings.general.promptActions.saveFailed',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (setPrompts) {
|
||||
const existingPromptIndex = prompts.findIndex(
|
||||
@@ -322,12 +353,7 @@ export default function Prompts({
|
||||
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
|
||||
{prompts.map((prompt) => {
|
||||
const isActive = selectedPrompt?.id === prompt.id;
|
||||
const canModify = prompt.type !== 'public';
|
||||
// Sharing is an owner-only action: hide it for public
|
||||
// prompts and prompts shared into the workspace by a
|
||||
// team.
|
||||
const canShare =
|
||||
prompt.type !== 'public' && prompt.type !== 'team';
|
||||
const canEdit = canEditPrompt(prompt);
|
||||
return (
|
||||
<CommandItem
|
||||
key={prompt.id}
|
||||
@@ -346,29 +372,31 @@ export default function Prompts({
|
||||
openEditModal(prompt);
|
||||
}}
|
||||
label={
|
||||
canModify
|
||||
canEdit
|
||||
? t('settings.general.promptActions.edit')
|
||||
: t('settings.general.promptActions.view')
|
||||
}
|
||||
>
|
||||
{canModify ? (
|
||||
{canEdit ? (
|
||||
<Pencil className="text-current" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="text-current" aria-hidden="true" />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicatePrompt(prompt);
|
||||
}}
|
||||
label={t('settings.general.promptActions.duplicate')}
|
||||
>
|
||||
<Copy className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
{canShare && (
|
||||
{can(prompt, 'duplicate') && (
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicatePrompt(prompt);
|
||||
}}
|
||||
label={t('settings.general.promptActions.duplicate')}
|
||||
>
|
||||
<Copy className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canSharePrompt(prompt) && (
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
@@ -385,7 +413,7 @@ export default function Prompts({
|
||||
<Users className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canModify && (
|
||||
{canDeletePrompt(prompt) && (
|
||||
<IconButton
|
||||
variant="ghost-destructive-on-accent"
|
||||
size="icon-xs"
|
||||
@@ -408,12 +436,16 @@ export default function Prompts({
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const editButton = selectedPrompt?.id && selectedPrompt.type !== 'public' && (
|
||||
// The listed row carries the access fields; a stored selection may not.
|
||||
const selectedListed =
|
||||
prompts.find((prompt) => prompt.id === selectedPrompt?.id) ??
|
||||
selectedPrompt;
|
||||
const editButton = selectedPrompt?.id && canEditPrompt(selectedListed) && (
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
onClick={() => openEditModal(selectedPrompt)}
|
||||
onClick={() => openEditModal(selectedListed)}
|
||||
label={t('settings.general.promptActions.edit')}
|
||||
icon={Pencil}
|
||||
/>
|
||||
@@ -502,7 +534,16 @@ export default function Prompts({
|
||||
currentPromptEdit={currentPromptEdit}
|
||||
handleAddPrompt={handleAddPrompt}
|
||||
handleEditPrompt={handleSaveChanges}
|
||||
onDuplicate={handleDuplicateFromModal}
|
||||
readOnly={editReadOnly}
|
||||
onDuplicate={
|
||||
can(
|
||||
prompts.find((prompt) => prompt.id === currentPromptEdit.id) ??
|
||||
currentPromptEdit,
|
||||
'duplicate',
|
||||
)
|
||||
? handleDuplicateFromModal
|
||||
: undefined
|
||||
}
|
||||
duplicateSourceName={duplicateSource}
|
||||
/>
|
||||
{promptToDelete && (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { updateSourceConfig: vi.fn() },
|
||||
}));
|
||||
|
||||
import type { Doc } from '../models/misc';
|
||||
import SourceConfigModal from './SourceConfigModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const doc = (fields: Partial<Doc>): Doc => ({
|
||||
id: 'src-1',
|
||||
name: 'Contracts',
|
||||
date: '',
|
||||
model: '',
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('SourceConfigModal access', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (document: Doc) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SourceConfigModal
|
||||
modalState="ACTIVE"
|
||||
setModalState={vi.fn()}
|
||||
document={document}
|
||||
onReingest={vi.fn()}
|
||||
onEnableGraphRAG={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const readOnlyNotice = () =>
|
||||
document.body.textContent?.includes(
|
||||
'settings.sources.configModal.readOnly',
|
||||
);
|
||||
|
||||
it('a viewer with view_config only sees the read-only notice', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
allowed_actions: ['use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it('an editor can edit (no read-only notice)', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: ['edit', 'use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(false);
|
||||
});
|
||||
|
||||
it('follows allowed_actions over the legacy team_access', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: ['use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it('an owned source without access fields is editable', async () => {
|
||||
await render(doc({}));
|
||||
expect(readOnlyNotice()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Modal, ModalActions } from '../components/ui/modal';
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { can } from '../utils/accessUtils';
|
||||
|
||||
import RetrievalOptions, {
|
||||
chunkingChanged,
|
||||
@@ -47,10 +48,9 @@ export default function SourceConfigModal({
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
// 'team' viewers cannot write; the backend rejects with 403, but we also
|
||||
// disable the form up-front for a clearer read-only experience.
|
||||
const isReadOnly =
|
||||
document?.ownership === 'team' && document?.team_access !== 'editor';
|
||||
// Without `edit` (a viewer opening View config) the form is read-only; the
|
||||
// backend rejects a write with 403 anyway.
|
||||
const isReadOnly = !!document && !can(document, 'edit');
|
||||
|
||||
const [initial, setInitial] = useState<RetrievalOptionsValue>(() =>
|
||||
configToOptions(document?.config),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { dispatch, service, view } = vi.hoisted(() => ({
|
||||
// The heavy children: each view reports the canEdit it was given.
|
||||
view:
|
||||
(testId: string) =>
|
||||
({ canEdit }: { canEdit?: boolean }) => (
|
||||
<div data-testid={testId} data-can-edit={String(canEdit)} />
|
||||
),
|
||||
dispatch: vi.fn(),
|
||||
service: {
|
||||
getConfig: vi.fn(),
|
||||
manageSync: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
syncConnector: vi.fn(),
|
||||
reingestSource: vi.fn(),
|
||||
getDirectoryStructure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => dispatch,
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: null },
|
||||
upload: { tasks: [] },
|
||||
graphBuild: { builds: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDebouncedValue: (value: unknown) => value,
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: service }));
|
||||
vi.mock('../api/services/modelService', () => ({
|
||||
default: { getModels: vi.fn(), transformModels: vi.fn(() => []) },
|
||||
}));
|
||||
vi.mock('../preferences/preferenceApi', () => ({
|
||||
getDocs: vi.fn(async () => []),
|
||||
getDocsWithPagination: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Chunks', () => ({ default: view('chunks') }));
|
||||
vi.mock('../components/FileTree', () => ({ default: view('file-tree') }));
|
||||
vi.mock('../components/ConnectorTree', () => ({
|
||||
default: view('connector-tree'),
|
||||
}));
|
||||
vi.mock('../components/WikiViewer', () => ({ default: view('wiki') }));
|
||||
vi.mock('../components/graph/GraphSourceView', () => ({
|
||||
default: view('graph'),
|
||||
}));
|
||||
vi.mock('./SourceConfigModal', () => ({ default: () => null }));
|
||||
vi.mock('./TestRetrievalModal', () => ({ default: () => null }));
|
||||
vi.mock('./ConvertToWikiModal', () => ({ default: () => null }));
|
||||
vi.mock('./EnableGraphRAGModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
|
||||
import type { Doc } from '../models/misc';
|
||||
import Sources from './Sources';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER = ['delete', 'edit', 'manage_settings', 'reconnect', 'share'];
|
||||
const EDITOR = ['edit', 'use', 'view_config'];
|
||||
const VIEWER = ['use', 'view_config'];
|
||||
|
||||
const doc = (fields: Partial<Doc> = {}): Doc => ({
|
||||
id: 'src-1',
|
||||
name: 'Contracts',
|
||||
date: '',
|
||||
model: '',
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('Sources access', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
Object.values(service).forEach((fn) => fn.mockReset());
|
||||
service.getConfig.mockResolvedValue({ json: async () => ({}) });
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (document: Doc) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Sources
|
||||
paginatedDocuments={[document]}
|
||||
handleDeleteDocument={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const menuItems = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="menu-button-src-1"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
const clickItem = async (label: string) => {
|
||||
const item = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((el) => el.textContent === label)!;
|
||||
await act(async () => item.click());
|
||||
};
|
||||
|
||||
const toasts = () =>
|
||||
dispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter((action) => action?.type === 'actionToast/showActionToast');
|
||||
|
||||
it('owner: Edit config, Test retrieval, Convert, Share and Delete', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'owner',
|
||||
allowed_actions: [...OWNER, 'use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.editConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
'settings.sources.wiki.convert.action',
|
||||
'settings.sources.shareWithTeam',
|
||||
'convTile.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a source with no access fields is the caller’s own', async () => {
|
||||
await render(doc());
|
||||
const items = await menuItems();
|
||||
expect(items).toContain('settings.sources.shareWithTeam');
|
||||
expect(items).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('editor: edits but cannot share or delete', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: EDITOR,
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.editConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
'settings.sources.wiki.convert.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('editors_can_share / editors_can_delete widen the editor menu', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
allowed_actions: [...EDITOR, 'share', 'delete'],
|
||||
}),
|
||||
);
|
||||
const items = await menuItems();
|
||||
expect(items).toContain('settings.sources.shareWithTeam');
|
||||
expect(items).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('viewer: View config and Test retrieval only', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
allowed_actions: VIEWER,
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.viewConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('viewer without view_config: no config item', async () => {
|
||||
await render(
|
||||
doc({ access: 'viewer', ownership: 'team', allowed_actions: ['use'] }),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.testRetrieval.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sync and reingest are editor actions', async () => {
|
||||
const synced = { syncFrequency: 'daily', ingestStatus: 'failed' as const };
|
||||
await render(doc({ ...synced, access: 'editor', allowed_actions: EDITOR }));
|
||||
let items = await menuItems();
|
||||
expect(items).toContain('settings.sources.reingest');
|
||||
expect(items).toContain('settings.sources.syncNow');
|
||||
expect(items).toContain('settings.sources.syncFrequency.option');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
document.body.querySelectorAll('[role="menu"]').forEach((m) => m.remove());
|
||||
await render(doc({ ...synced, access: 'viewer', allowed_actions: VIEWER }));
|
||||
items = await menuItems();
|
||||
expect(items).not.toContain('settings.sources.reingest');
|
||||
expect(items).not.toContain('settings.sources.syncNow');
|
||||
expect(items).not.toContain('settings.sources.syncFrequency.option');
|
||||
});
|
||||
|
||||
it('a failed Sync now shows an error toast', async () => {
|
||||
service.syncSource.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
await render(doc({ syncFrequency: 'daily' }));
|
||||
await menuItems();
|
||||
await clickItem('settings.sources.syncNow');
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ variant: 'destructive' }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('a failed sync-frequency change shows an error toast', async () => {
|
||||
service.manageSync.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false }),
|
||||
});
|
||||
await render(doc({ syncFrequency: 'daily' }));
|
||||
await menuItems();
|
||||
const weekly = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).filter(
|
||||
(el) => el.textContent === 'settings.sources.syncFrequency.option',
|
||||
)[2];
|
||||
await act(async () => weekly.click());
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed reingest shows an error toast', async () => {
|
||||
service.reingestSource.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
await render(doc({ ingestStatus: 'failed' }));
|
||||
await menuItems();
|
||||
await clickItem('settings.sources.reingest');
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
const openView = async (document: Doc) => {
|
||||
await render(document);
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[aria-label="Contracts"]')!.click(),
|
||||
);
|
||||
};
|
||||
|
||||
const canEditOf = (testId: string) =>
|
||||
container
|
||||
.querySelector(`[data-testid="${testId}"]`)
|
||||
?.getAttribute('data-can-edit');
|
||||
|
||||
it.each([
|
||||
['chunks', {}],
|
||||
['file-tree', { isNested: true }],
|
||||
['connector-tree', { isNested: true, type: 'connector:file' }],
|
||||
['wiki', { type: 'wiki' }],
|
||||
['graph', { config: { kind: 'graphrag' } }],
|
||||
] as const)(
|
||||
'the %s view is read-only for a viewer',
|
||||
async (testId, fields) => {
|
||||
await openView(
|
||||
doc({ ...fields, access: 'viewer', allowed_actions: VIEWER }),
|
||||
);
|
||||
expect(canEditOf(testId)).toBe('false');
|
||||
},
|
||||
);
|
||||
|
||||
it('the source view is editable for an editor', async () => {
|
||||
await openView(
|
||||
doc({ isNested: true, access: 'editor', allowed_actions: EDITOR }),
|
||||
);
|
||||
expect(canEditOf('file-tree')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import { useDebouncedValue, useLoaderState } from '../hooks';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
selectUploadTasks,
|
||||
updateUploadTask,
|
||||
} from '../upload/uploadSlice';
|
||||
import { can, roleOf } from '../utils/accessUtils';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import FileTree from '../components/FileTree';
|
||||
import ConnectorTree from '../components/ConnectorTree';
|
||||
@@ -128,6 +130,19 @@ export default function Sources({
|
||||
// badge survives closing the modal and reflects the real backend state.
|
||||
const graphBuilds = useSelector(selectGraphBuilds);
|
||||
|
||||
/**
|
||||
* Shows a failed source action as a destructive toast: the forbidden
|
||||
* message on a 403, else the action's own.
|
||||
*/
|
||||
const showActionError = (message: string, status?: number) =>
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message:
|
||||
status === 403 ? t('settings.sources.errors.forbidden') : message,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshDocs = useCallback(
|
||||
(
|
||||
field: 'date' | 'tokens' | undefined,
|
||||
@@ -179,10 +194,18 @@ export default function Sources({
|
||||
setLoading(true);
|
||||
userService
|
||||
.manageSync({ source_id: doc.id, sync_frequency }, token)
|
||||
.then(() => {
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) {
|
||||
showActionError(
|
||||
t('settings.sources.errors.syncFrequency'),
|
||||
response.status,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return getDocs(token);
|
||||
})
|
||||
.then((data) => {
|
||||
if (data === null) return null;
|
||||
dispatch(setSourceDocs(data));
|
||||
return getDocsWithPagination(
|
||||
sortField,
|
||||
@@ -194,12 +217,16 @@ export default function Sources({
|
||||
);
|
||||
})
|
||||
.then((paginatedData) => {
|
||||
if (paginatedData === null) return;
|
||||
dispatch(
|
||||
setPaginatedDocuments(paginatedData ? paginatedData.docs : []),
|
||||
);
|
||||
setTotalPages(paginatedData ? paginatedData.totalPages : 0);
|
||||
})
|
||||
.catch((error) => console.error('Error in handleManageSync:', error))
|
||||
.catch((error) => {
|
||||
console.error('Error in handleManageSync:', error);
|
||||
showActionError(t('settings.sources.errors.syncFrequency'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -229,34 +256,28 @@ export default function Sources({
|
||||
if (!doc.id) {
|
||||
return;
|
||||
}
|
||||
const syncFailed = t('settings.sources.errors.sync');
|
||||
try {
|
||||
let response: Response;
|
||||
if (doc.type?.startsWith('connector')) {
|
||||
const provider = await getConnectorProvider(doc);
|
||||
if (!provider) {
|
||||
console.error('Sync now failed: provider not found');
|
||||
showActionError(syncFailed);
|
||||
return;
|
||||
}
|
||||
const response = await userService.syncConnector(
|
||||
doc.id,
|
||||
provider,
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Sync now failed:', data.error || data.message);
|
||||
}
|
||||
return;
|
||||
response = await userService.syncConnector(doc.id, provider, token);
|
||||
} else {
|
||||
response = await userService.syncSource({ source_id: doc.id }, token);
|
||||
}
|
||||
const response = await userService.syncSource(
|
||||
{ source_id: doc.id },
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Sync now failed:', data.error || data.message);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success) {
|
||||
console.error('Sync now failed:', data?.error || data?.message);
|
||||
showActionError(syncFailed, response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing source:', error);
|
||||
showActionError(syncFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,23 +306,25 @@ export default function Sources({
|
||||
{ source_id: sourceId },
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Reingest failed:', data.error || data.message);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success) {
|
||||
console.error('Reingest failed:', data?.error || data?.message);
|
||||
dispatch(
|
||||
updateUploadTask({
|
||||
id: reingestTaskId,
|
||||
updates: {
|
||||
status: 'failed',
|
||||
errorMessage: data.error || data.message,
|
||||
errorMessage: data?.error || data?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
showActionError(t('settings.sources.errors.reingest'), response.status);
|
||||
return;
|
||||
}
|
||||
refreshDocs(undefined, currentPage, rowsPerPage);
|
||||
} catch (error) {
|
||||
console.error('Error reingesting source:', error);
|
||||
showActionError(t('settings.sources.errors.reingest'));
|
||||
dispatch(
|
||||
updateUploadTask({
|
||||
id: reingestTaskId,
|
||||
@@ -334,9 +357,8 @@ export default function Sources({
|
||||
const getActionOptions = (index: number, document: Doc): MenuOption[] => {
|
||||
const isWiki = document.config?.kind === 'wiki' || document.type === 'wiki';
|
||||
const isGraphRAG = document.config?.kind === 'graphrag';
|
||||
// 'team' viewers cannot write; convert is owner/editor only.
|
||||
const canEdit =
|
||||
document.ownership !== 'team' || document.team_access === 'editor';
|
||||
// The server's allowed_actions decide every write (utils/accessUtils).
|
||||
const canEdit = can(document, 'edit');
|
||||
const actions: MenuOption[] = [
|
||||
{
|
||||
icon: isGraphRAG ? Network : Eye,
|
||||
@@ -352,7 +374,7 @@ export default function Sources({
|
||||
},
|
||||
];
|
||||
|
||||
if (document.ingestStatus === 'failed') {
|
||||
if (canEdit && document.ingestStatus === 'failed') {
|
||||
actions.push({
|
||||
icon: RefreshCw,
|
||||
label: t('settings.sources.reingest'),
|
||||
@@ -363,7 +385,7 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
if (document.syncFrequency) {
|
||||
if (canEdit && document.syncFrequency) {
|
||||
// One row per sync frequency; the current one carries the check.
|
||||
syncOptions.forEach((opt) => {
|
||||
actions.push({
|
||||
@@ -387,10 +409,13 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
if (document.id && !isWiki) {
|
||||
// Editors edit the config; a viewer may read it (view_config).
|
||||
if (document.id && !isWiki && (canEdit || can(document, 'view_config'))) {
|
||||
actions.push({
|
||||
icon: SlidersHorizontal,
|
||||
label: t('settings.sources.editConfig'),
|
||||
label: canEdit
|
||||
? t('settings.sources.editConfig')
|
||||
: t('settings.sources.viewConfig'),
|
||||
onClick: () => {
|
||||
setDocumentToConfigure(document);
|
||||
setConfigModalState('ACTIVE');
|
||||
@@ -429,9 +454,8 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
// Sharing is an owner-only action: hide it for sources shared into the
|
||||
// user's workspace by a team.
|
||||
if (document.ownership !== 'team' && document.id) {
|
||||
// Owner-only unless the owner lets editors share (editors_can_share).
|
||||
if (document.id && can(document, 'share')) {
|
||||
actions.push({
|
||||
icon: Users,
|
||||
label: t('settings.sources.shareWithTeam'),
|
||||
@@ -442,14 +466,16 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
icon: Trash2,
|
||||
label: t('convTile.delete'),
|
||||
onClick: () => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
},
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (can(document, 'delete')) {
|
||||
actions.push({
|
||||
icon: Trash2,
|
||||
label: t('convTile.delete'),
|
||||
onClick: () => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
},
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
@@ -521,6 +547,9 @@ export default function Sources({
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// Chunk, file, wiki and graph writes follow the source's `edit` action.
|
||||
const viewCanEdit = documentToView ? can(documentToView, 'edit') : false;
|
||||
|
||||
return documentToView ? (
|
||||
<div className="flex flex-col">
|
||||
{documentToView.config?.kind === 'wiki' ||
|
||||
@@ -528,10 +557,7 @@ export default function Sources({
|
||||
<WikiViewer
|
||||
docId={documentToView.id || ''}
|
||||
sourceName={documentToView.name}
|
||||
canEdit={
|
||||
documentToView.ownership !== 'team' ||
|
||||
documentToView.team_access === 'editor'
|
||||
}
|
||||
canEdit={viewCanEdit}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -541,6 +567,7 @@ export default function Sources({
|
||||
sourceName={documentToView.name}
|
||||
sourceType={documentToView.type}
|
||||
isNested={!!documentToView.isNested}
|
||||
canEdit={viewCanEdit}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -548,6 +575,7 @@ export default function Sources({
|
||||
documentToView.type === 'connector:file' ? (
|
||||
<ConnectorTree
|
||||
docId={documentToView.id || ''}
|
||||
canEdit={viewCanEdit}
|
||||
sourceName={documentToView.name}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
@@ -555,6 +583,7 @@ export default function Sources({
|
||||
) : (
|
||||
<FileTree
|
||||
docId={documentToView.id || ''}
|
||||
canEdit={viewCanEdit}
|
||||
sourceName={documentToView.name}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
@@ -564,6 +593,7 @@ export default function Sources({
|
||||
<Chunks
|
||||
documentId={documentToView.id || ''}
|
||||
documentName={documentToView.name}
|
||||
canEdit={viewCanEdit}
|
||||
handleGoBack={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -663,10 +693,10 @@ export default function Sources({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-1">
|
||||
{document.ownership === 'team' && (
|
||||
{roleOf(document) !== 'owner' && (
|
||||
<Badge variant="neutral">
|
||||
<Users className="size-3" aria-hidden="true" />
|
||||
{document.team_access === 'editor'
|
||||
{roleOf(document) === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</Badge>
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
// A JWT whose payload is {"sub":"me"}.
|
||||
const TOKEN = `x.${btoa(JSON.stringify({ sub: 'me' }))}.y`;
|
||||
|
||||
const mockState = {
|
||||
preference: {
|
||||
token: TOKEN,
|
||||
agents: [],
|
||||
sourceDocs: [],
|
||||
prompts: [],
|
||||
},
|
||||
teams: {
|
||||
teams: [] as Array<Record<string, unknown>>,
|
||||
currentTeamId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => () => ({ unwrap: () => Promise.resolve() }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts) return key;
|
||||
const params = Object.entries(opts)
|
||||
.filter(([k]) => k !== 'defaultValue' && k !== 'interpolation')
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(',');
|
||||
return params ? `${key}(${params})` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/SectionShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../navigation/DetailBreadcrumb', () => ({ default: () => null }));
|
||||
vi.mock('../components/PageToolbar', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({
|
||||
default: () => <div data-testid="share-modal" />,
|
||||
}));
|
||||
// Render the ⋯ menu's options inline so tests can see them.
|
||||
vi.mock('../components/ui/dropdown-menu', () => ({
|
||||
ActionMenu: ({ options }: { options: Array<{ label: string }> }) => (
|
||||
<div data-testid="team-menu">
|
||||
{options.map((o) => (
|
||||
<span key={o.label}>{o.label}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgents: () => Promise.resolve({ json: () => Promise.resolve([]) }),
|
||||
getUserTools: () =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ tools: [] }) }),
|
||||
},
|
||||
}));
|
||||
|
||||
const listMembers = vi.fn();
|
||||
const listGrants = vi.fn();
|
||||
const unshare = vi.fn();
|
||||
const share = vi.fn();
|
||||
const getResourceSettings = vi.fn();
|
||||
|
||||
vi.mock('../api/services/teamsService', () => ({
|
||||
default: {
|
||||
listMembers: (...a: unknown[]) => listMembers(...a),
|
||||
listGrants: (...a: unknown[]) => listGrants(...a),
|
||||
unshare: (...a: unknown[]) => unshare(...a),
|
||||
share: (...a: unknown[]) => share(...a),
|
||||
getResourceSettings: (...a: unknown[]) => getResourceSettings(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import Teams from './Teams';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER = {
|
||||
access: 'owner',
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'edit',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
],
|
||||
};
|
||||
const VIEWER = { access: 'viewer', allowed_actions: ['pin', 'use'] };
|
||||
|
||||
const grant = (over: Record<string, unknown> = {}) => ({
|
||||
resource_type: 'source',
|
||||
resource_id: 's1',
|
||||
access_level: 'viewer',
|
||||
target_user_id: null,
|
||||
resource_name: 'Key Accounts',
|
||||
owner_id: 'lena',
|
||||
owner_label: 'Lena Fischer',
|
||||
target_user_label: null,
|
||||
created_at: '2026-09-12T10:00:00Z',
|
||||
granted_by_label: 'Lena Fischer',
|
||||
caller: OWNER,
|
||||
...over,
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const body = () => document.body;
|
||||
const rowButtons = () =>
|
||||
Array.from(
|
||||
body().querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="shared-resource-row"]',
|
||||
),
|
||||
);
|
||||
|
||||
describe('Teams page', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const setTeam = (over: Record<string, unknown> = {}) => {
|
||||
mockState.teams.teams = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Revenue Ops',
|
||||
slug: 'revenue-ops',
|
||||
owner_id: 'me',
|
||||
member_role: 'team_admin',
|
||||
...over,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setTeam();
|
||||
listMembers.mockReset().mockResolvedValue({ members: [] });
|
||||
listGrants
|
||||
.mockReset()
|
||||
.mockResolvedValue({ grants: [], team_role: 'team_admin' });
|
||||
unshare.mockReset().mockResolvedValue({ success: true });
|
||||
share.mockReset().mockResolvedValue({ success: true });
|
||||
getResourceSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
resource_type: 'source',
|
||||
resource_id: 's1',
|
||||
settings: [
|
||||
{ key: 'editors_can_share', value: false, default: false },
|
||||
{ key: 'editors_can_delete', value: false, default: false },
|
||||
{ key: 'viewers_can_see_config', value: true, default: true },
|
||||
],
|
||||
access: 'owner',
|
||||
allowed_actions: OWNER.allowed_actions,
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: '/teams', state: { openTeamId: 't1' } }]}
|
||||
>
|
||||
<Teams />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
};
|
||||
|
||||
const openDrawer = async (index = 0) => {
|
||||
act(() => rowButtons()[index].click());
|
||||
await flush();
|
||||
};
|
||||
|
||||
it('groups duplicate grants into one row per resource', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [
|
||||
grant(),
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
grant({
|
||||
resource_type: 'prompt',
|
||||
resource_id: 's1',
|
||||
resource_name: 'Pre-call brief',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
const rows = rowButtons();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('Key Accounts');
|
||||
expect(rows[0].textContent).toContain(
|
||||
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1)',
|
||||
);
|
||||
expect(rows[0].textContent).toContain(
|
||||
'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)',
|
||||
);
|
||||
// Filter pills with counts.
|
||||
const pills = Array.from(body().querySelectorAll('[role="radio"]'));
|
||||
expect(pills.map((p) => p.textContent)).toEqual([
|
||||
'settings.teams.sharedList.filter.all 2',
|
||||
'settings.teams.sharedList.filter.agent 0',
|
||||
'settings.teams.sharedList.filter.source 1',
|
||||
'settings.teams.sharedList.filter.tool 0',
|
||||
'settings.teams.sharedList.filter.prompt 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows role selects, remove and Manage sharing to a caller who can share', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_member',
|
||||
grants: [
|
||||
grant(),
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
],
|
||||
});
|
||||
setTeam({ member_role: 'team_member' });
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]');
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.drawer.accessIn(team=Revenue Ops)',
|
||||
);
|
||||
expect(sheet!.querySelectorAll('[role="combobox"]')).toHaveLength(2);
|
||||
expect(
|
||||
sheet!.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(sheet!.textContent).toContain('settings.teams.drawer.manageSharing');
|
||||
// What people here can do, from the settings.
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.capabilities.source.viewers',
|
||||
);
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.capabilities.switch.editors_can_share.off',
|
||||
);
|
||||
// The selected row keeps its tint while the drawer is open.
|
||||
expect(rowButtons()[0].className).toContain('bg-secondary');
|
||||
});
|
||||
|
||||
it('gives a team admin who cannot share badges and remove only', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [grant({ caller: VIEWER })],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.querySelectorAll('[role="combobox"]')).toHaveLength(0);
|
||||
expect(
|
||||
sheet.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(sheet.textContent).not.toContain(
|
||||
'settings.teams.drawer.manageSharing',
|
||||
);
|
||||
});
|
||||
|
||||
it('is read-only for a member who cannot share', async () => {
|
||||
setTeam({ member_role: 'team_member', owner_id: 'someone' });
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_member',
|
||||
grants: [grant({ caller: VIEWER })],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.querySelectorAll('[role="combobox"]')).toHaveLength(0);
|
||||
expect(
|
||||
sheet.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(sheet.textContent).toContain(
|
||||
'settings.teams.drawer.open(type=settings.teams.resourceType.source)',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends target_user_id when removing a per-member grant', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const remove = body().querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
);
|
||||
act(() => remove!.click());
|
||||
await flush();
|
||||
expect(unshare).toHaveBeenCalledWith(
|
||||
't1',
|
||||
{ resource_type: 'source', resource_id: 's1', target_user_id: 'dana' },
|
||||
TOKEN,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Delete team only to the team owner', async () => {
|
||||
await render();
|
||||
let menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).toContain('settings.teams.deleteTeam');
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
setTeam({ owner_id: 'someone-else', member_role: 'team_admin' });
|
||||
await render();
|
||||
menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).toContain('settings.teams.editTeam');
|
||||
expect(menu?.textContent).not.toContain('settings.teams.deleteTeam');
|
||||
});
|
||||
|
||||
it('prefers is_owner over owner_id when the server sends it', async () => {
|
||||
setTeam({ owner_id: 'me', is_owner: false, member_role: 'team_admin' });
|
||||
await render();
|
||||
const menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).not.toContain('settings.teams.deleteTeam');
|
||||
});
|
||||
});
|
||||
+658
-72
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
FileText,
|
||||
@@ -9,17 +11,27 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import teamsService, {
|
||||
AccessLevel,
|
||||
ResourceSettingsResponse,
|
||||
ResourceType,
|
||||
TeamGrant,
|
||||
TeamRole,
|
||||
} from '../api/services/teamsService';
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
agentChatPath,
|
||||
agentEditPath,
|
||||
agentEditPathFor,
|
||||
} from '../agents/paths';
|
||||
import SearchInput from '../components/SearchInput';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
@@ -34,6 +46,10 @@ import {
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
} from '../components/ui/card';
|
||||
import {
|
||||
DescriptionItem,
|
||||
DescriptionList,
|
||||
} from '../components/ui/description-list';
|
||||
import { ActionMenu } from '../components/ui/dropdown-menu';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
import { FormField } from '../components/ui/form-field';
|
||||
@@ -49,7 +65,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '../components/ui/sheet';
|
||||
import { Textarea } from '../components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
@@ -61,6 +85,12 @@ import {
|
||||
setAgents,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
import {
|
||||
capabilityLines,
|
||||
errorMessage,
|
||||
resolveSettings,
|
||||
} from '../teams/accessSettings';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import {
|
||||
createTeam,
|
||||
deleteTeam,
|
||||
@@ -70,6 +100,9 @@ import {
|
||||
selectTeamsLoading,
|
||||
Team,
|
||||
} from '../teams/teamsSlice';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { formatDateOnly } from '../utils/dateTimeUtils';
|
||||
import { decodeJwtPayload } from '../utils/jwtUtils';
|
||||
|
||||
type Member = {
|
||||
user_id: string;
|
||||
@@ -78,12 +111,50 @@ type Member = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
type Grant = {
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
access_level: string;
|
||||
type Grant = TeamGrant;
|
||||
|
||||
// All of one team's grants on one resource: the whole-team grant (if any)
|
||||
// and the per-member grants, shown as a single row.
|
||||
type SharedResource = {
|
||||
key: string;
|
||||
type: ResourceType;
|
||||
id: string;
|
||||
grants: Grant[];
|
||||
teamGrant: Grant | null;
|
||||
memberGrants: Grant[];
|
||||
};
|
||||
|
||||
type ResourceFilter = 'all' | ResourceType;
|
||||
|
||||
const resourceKey = (g: Pick<Grant, 'resource_type' | 'resource_id'>) =>
|
||||
`${g.resource_type}:${g.resource_id}`;
|
||||
|
||||
const grantKey = (g: Grant) => `${resourceKey(g)}:${g.target_user_id ?? ''}`;
|
||||
|
||||
/** Group grants by resource, keeping the server's order of first sighting. */
|
||||
export function groupGrants(grants: Grant[]): SharedResource[] {
|
||||
const byKey = new Map<string, SharedResource>();
|
||||
grants.forEach((g) => {
|
||||
const key = resourceKey(g);
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
key,
|
||||
type: g.resource_type,
|
||||
id: g.resource_id,
|
||||
grants: [],
|
||||
teamGrant: null,
|
||||
memberGrants: [],
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
entry.grants.push(g);
|
||||
if (g.target_user_id) entry.memberGrants.push(g);
|
||||
else entry.teamGrant = g;
|
||||
});
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
const RESOURCE_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'agent',
|
||||
'source',
|
||||
@@ -91,6 +162,14 @@ const RESOURCE_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'tool',
|
||||
];
|
||||
|
||||
// Filter pill order on the shared resources list.
|
||||
const FILTER_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'agent',
|
||||
'source',
|
||||
'tool',
|
||||
'prompt',
|
||||
];
|
||||
|
||||
// Member subs (OIDC subs) can be long; truncate the middle for readability
|
||||
// while keeping the ends identifiable when no email is available.
|
||||
const truncateSub = (sub: string): string =>
|
||||
@@ -145,6 +224,26 @@ export default function Teams() {
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [memberToRemove, setMemberToRemove] = useState<string | null>(null);
|
||||
|
||||
// The caller's role in the selected team, as the grants endpoint reports it.
|
||||
const [teamRole, setTeamRole] = useState<TeamRole | null>(null);
|
||||
// Shared resources list: type filter, search, and the row whose drawer is
|
||||
// open (kept while "Manage sharing" has the drawer closed).
|
||||
const [resourceFilter, setResourceFilter] = useState<ResourceFilter>('all');
|
||||
const [resourceQuery, setResourceQuery] = useState('');
|
||||
const [openResourceKey, setOpenResourceKey] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerSettings, setDrawerSettings] =
|
||||
useState<ResourceSettingsResponse | null>(null);
|
||||
const [busyGrants, setBusyGrants] = useState<Set<string>>(new Set());
|
||||
const [shareTarget, setShareTarget] = useState<SharedResource | null>(null);
|
||||
|
||||
// The caller's own sub, to tell whether they own the selected team when
|
||||
// the server doesn't send `is_owner`.
|
||||
const currentUserId = useMemo(() => {
|
||||
const payload = token ? decodeJwtPayload(token) : null;
|
||||
return typeof payload?.sub === 'string' ? payload.sub : undefined;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadTeams({ token }));
|
||||
}, []);
|
||||
@@ -173,6 +272,7 @@ export default function Teams() {
|
||||
// render so names appear as soon as those lists hydrate. Falls back to a
|
||||
// truncated id when the resource isn't found (e.g. not yet loaded).
|
||||
const resolveResourceName = (g: Grant): string => {
|
||||
if (g.resource_name) return g.resource_name;
|
||||
switch (g.resource_type) {
|
||||
case 'agent':
|
||||
return (
|
||||
@@ -227,11 +327,17 @@ export default function Teams() {
|
||||
// members/grants while this team's fetch is in flight.
|
||||
setMembers([]);
|
||||
setGrants([]);
|
||||
setTeamRole(null);
|
||||
setOpenResourceKey(null);
|
||||
setDrawerOpen(false);
|
||||
setResourceFilter('all');
|
||||
setResourceQuery('');
|
||||
try {
|
||||
const m = await teamsService.listMembers(team.id, token);
|
||||
setMembers(m?.members ?? []);
|
||||
const g = await teamsService.listGrants(team.id, undefined, token);
|
||||
setGrants(g?.grants ?? []);
|
||||
setTeamRole(g?.team_role ?? null);
|
||||
// Agents/sources/prompts are normally hydrated at app init, but a fresh
|
||||
// load landing directly on /teams may not have agents yet. Backfill them
|
||||
// (only when missing and an agent is actually shared) so the row resolves
|
||||
@@ -332,15 +438,15 @@ export default function Teams() {
|
||||
token,
|
||||
);
|
||||
if (!res || res.success === false) {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
setEditError(res?.message ?? t('settings.teams.updateFailed'));
|
||||
return;
|
||||
}
|
||||
// Reflect locally and refresh the list so the card/switcher update too.
|
||||
setSelected({ ...selected, name, description });
|
||||
dispatch(loadTeams({ token }));
|
||||
setEditOpen(false);
|
||||
} catch {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
} catch (error) {
|
||||
setEditError(errorMessage(error, t('settings.teams.updateFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -412,8 +518,12 @@ export default function Teams() {
|
||||
setNewMemberRole('team_member');
|
||||
setAddMemberOpen(false);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
setAddMemberError(t('settings.teams.addMemberError'));
|
||||
} catch (error) {
|
||||
// The backend returns 404 with a message when the email maps to no
|
||||
// known user ("they must sign in first"); show its message.
|
||||
setAddMemberError(
|
||||
errorMessage(error, t('settings.teams.addMemberError')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -429,8 +539,8 @@ export default function Teams() {
|
||||
if (res?.success === false)
|
||||
reportError(res.message ?? t('settings.teams.updateFailed'));
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.roleChangeError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.roleChangeError')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -446,8 +556,8 @@ export default function Teams() {
|
||||
try {
|
||||
await teamsService.removeMember(selected.id, memberId, token);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.removeMemberError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.removeMemberError')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,29 +573,200 @@ export default function Teams() {
|
||||
try {
|
||||
await dispatch(deleteTeam({ id: team.id, token })).unwrap();
|
||||
if (selected?.id === team.id) setSelected(null);
|
||||
} catch {
|
||||
reportError(t('settings.teams.deleteTeamError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.deleteTeamError')));
|
||||
}
|
||||
};
|
||||
|
||||
// Re-read the team's grants after a change (the drawer follows them).
|
||||
const refreshGrants = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
const g = await teamsService.listGrants(selected.id, undefined, token);
|
||||
setGrants(g?.grants ?? []);
|
||||
setTeamRole(g?.team_role ?? null);
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.openTeamError')));
|
||||
}
|
||||
};
|
||||
|
||||
const setGrantBusy = (key: string, busy: boolean) =>
|
||||
setBusyGrants((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (busy) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove one grant: the whole-team grant, or one member's (which needs
|
||||
// its target_user_id, or the server would drop the team grant instead).
|
||||
const handleUnshare = async (grant: Grant) => {
|
||||
if (!selected) return;
|
||||
const key = grantKey(grant);
|
||||
setGrantBusy(key, true);
|
||||
try {
|
||||
await teamsService.unshare(
|
||||
selected.id,
|
||||
{
|
||||
resource_type: grant.resource_type as ResourceType,
|
||||
resource_type: grant.resource_type,
|
||||
resource_id: grant.resource_id,
|
||||
target_user_id: grant.target_user_id ?? undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.unshareError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.unshareError')));
|
||||
} finally {
|
||||
setGrantBusy(key, false);
|
||||
await refreshGrants();
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = selected?.member_role === 'team_admin';
|
||||
const handleGrantAccess = async (grant: Grant, level: AccessLevel) => {
|
||||
if (!selected || grant.access_level === level) return;
|
||||
const key = grantKey(grant);
|
||||
setGrantBusy(key, true);
|
||||
try {
|
||||
await teamsService.share(
|
||||
selected.id,
|
||||
{
|
||||
resource_type: grant.resource_type,
|
||||
resource_id: grant.resource_id,
|
||||
access_level: level,
|
||||
target_user_id: grant.target_user_id ?? undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.accessChangeError')));
|
||||
} finally {
|
||||
setGrantBusy(key, false);
|
||||
await refreshGrants();
|
||||
}
|
||||
};
|
||||
|
||||
// The grants endpoint's live team_role wins over the list's member_role.
|
||||
const isAdmin = (teamRole ?? selected?.member_role) === 'team_admin';
|
||||
// Only the team's owner may delete it. Prefer the server's `is_owner`;
|
||||
// older payloads only carry `owner_id`.
|
||||
const isTeamOwner = selected
|
||||
? typeof selected.is_owner === 'boolean'
|
||||
? selected.is_owner
|
||||
: Boolean(currentUserId) && selected.owner_id === currentUserId
|
||||
: false;
|
||||
|
||||
const sharedResources = useMemo(() => groupGrants(grants), [grants]);
|
||||
const resourceCounts = useMemo(() => {
|
||||
const counts: Record<ResourceFilter, number> = {
|
||||
all: sharedResources.length,
|
||||
agent: 0,
|
||||
source: 0,
|
||||
tool: 0,
|
||||
prompt: 0,
|
||||
};
|
||||
sharedResources.forEach((r) => {
|
||||
if (r.type in counts) counts[r.type] += 1;
|
||||
});
|
||||
return counts;
|
||||
}, [sharedResources]);
|
||||
|
||||
const resourceName = (r: SharedResource): string =>
|
||||
resolveResourceName(r.grants[0]);
|
||||
const ownerLabel = (r: SharedResource): string => {
|
||||
const g = r.grants[0];
|
||||
return g.owner_label || (g.owner_id ? truncateSub(g.owner_id) : '—');
|
||||
};
|
||||
|
||||
const visibleResources = sharedResources.filter((r) => {
|
||||
if (resourceFilter !== 'all' && r.type !== resourceFilter) return false;
|
||||
const needle = resourceQuery.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return `${resourceName(r)} ${ownerLabel(r)}`.toLowerCase().includes(needle);
|
||||
});
|
||||
|
||||
// The strongest access this team has, plus "+N Editor" when per-member
|
||||
// editor grants sit on top of a viewer team grant.
|
||||
const resourceBadge = (r: SharedResource): string => {
|
||||
const memberEditors = r.memberGrants.filter(
|
||||
(g) => g.access_level === 'editor',
|
||||
).length;
|
||||
if (r.teamGrant) {
|
||||
const level = accessLevelLabel(r.teamGrant.access_level);
|
||||
return r.teamGrant.access_level === 'viewer' && memberEditors > 0
|
||||
? t('settings.teams.sharedList.badgeWithEditors', {
|
||||
interpolation: { escapeValue: false },
|
||||
level,
|
||||
count: memberEditors,
|
||||
})
|
||||
: level;
|
||||
}
|
||||
return accessLevelLabel(memberEditors > 0 ? 'editor' : 'viewer');
|
||||
};
|
||||
|
||||
const openResource =
|
||||
sharedResources.find((r) => r.key === openResourceKey) ?? null;
|
||||
const openCaller = openResource?.grants.find((g) => g.caller)?.caller ?? null;
|
||||
const callerCanShare = can(openCaller, 'share');
|
||||
|
||||
const openDrawerFor = (r: SharedResource) => {
|
||||
setOpenResourceKey(r.key);
|
||||
setDrawerOpen(true);
|
||||
setDrawerSettings(null);
|
||||
teamsService
|
||||
.getResourceSettings(r.type, r.id, token)
|
||||
.then((res) => setDrawerSettings(res))
|
||||
.catch(() => {
|
||||
// The capabilities list falls back to the default rules.
|
||||
});
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setOpenResourceKey(null);
|
||||
};
|
||||
|
||||
// Where "Open {{type}}" goes: an agent's edit page when the caller may
|
||||
// view its config, else its chat; the list page for the other types.
|
||||
const openAssetPath = (r: SharedResource): string => {
|
||||
switch (r.type) {
|
||||
case 'agent': {
|
||||
if (!can(openCaller, 'view')) return agentChatPath(r.id);
|
||||
const agent = agents?.find((a) => a.id === r.id);
|
||||
return agent ? agentEditPathFor(agent) : agentEditPath(r.id);
|
||||
}
|
||||
case 'source':
|
||||
return '/settings/sources';
|
||||
case 'tool':
|
||||
return '/settings/tools';
|
||||
case 'prompt':
|
||||
return '/settings/general';
|
||||
default:
|
||||
return '/settings';
|
||||
}
|
||||
};
|
||||
|
||||
const callerAccessLabel = (access?: string | null): string =>
|
||||
access
|
||||
? t(`settings.teams.drawer.yourAccessLevel.${access}`, {
|
||||
interpolation: { escapeValue: false },
|
||||
defaultValue: access,
|
||||
})
|
||||
: t('settings.teams.drawer.yourAccessLevel.none');
|
||||
|
||||
const grantedAt = (r: SharedResource): string => {
|
||||
const first = [...r.grants]
|
||||
.filter((g) => g.created_at)
|
||||
.sort((a, b) => (a.created_at! < b.created_at! ? -1 : 1))[0];
|
||||
if (!first?.created_at) return '—';
|
||||
const date = formatDateOnly(first.created_at);
|
||||
return first.granted_by_label
|
||||
? t('settings.teams.drawer.sharedOnBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
date,
|
||||
name: first.granted_by_label,
|
||||
})
|
||||
: date;
|
||||
};
|
||||
|
||||
const roleBadge = (role: TeamRole) => (
|
||||
<Badge variant={role === 'team_admin' ? 'default' : 'neutral'}>
|
||||
@@ -631,20 +912,28 @@ export default function Teams() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{(isAdmin || isTeamOwner) && (
|
||||
<ActionMenu
|
||||
options={[
|
||||
{
|
||||
label: t('settings.teams.editTeam'),
|
||||
icon: Pencil,
|
||||
onClick: openEditModal,
|
||||
},
|
||||
{
|
||||
label: t('settings.teams.deleteTeam'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => requestDeleteTeam(selected),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
label: t('settings.teams.editTeam'),
|
||||
icon: Pencil,
|
||||
onClick: openEditModal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isTeamOwner
|
||||
? [
|
||||
{
|
||||
label: t('settings.teams.deleteTeam'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => requestDeleteTeam(selected),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
triggerLabel={t('settings.teams.teamActions')}
|
||||
className="shrink-0"
|
||||
@@ -737,54 +1026,350 @@ export default function Teams() {
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="sm"
|
||||
title={`${t('settings.teams.sharedResources')} · ${grants.length}`}
|
||||
title={`${t('settings.teams.sharedResources')} · ${sharedResources.length}`}
|
||||
/>
|
||||
{grants.length === 0 ? (
|
||||
{sharedResources.length === 0 ? (
|
||||
<EmptyState size="sm" title={t('settings.teams.nothingShared')} />
|
||||
) : (
|
||||
<ListRows>
|
||||
{grants.map((g) => (
|
||||
<ListRow
|
||||
key={`${g.resource_type}-${g.resource_id}`}
|
||||
leading={
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
title={resourceTypeLabel(g.resource_type)}
|
||||
>
|
||||
{resourceTypeIcon(g.resource_type)}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span title={g.resource_id}>
|
||||
{resolveResourceName(g)}
|
||||
</span>
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Badge variant="neutral">
|
||||
{accessLevelLabel(g.access_level)}
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
label={t('settings.teams.unshare')}
|
||||
icon={Trash2}
|
||||
onClick={() => handleUnshare(g)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div className="bg-muted max-w-full rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
value={resourceFilter}
|
||||
onValueChange={(value) =>
|
||||
value && setResourceFilter(value as ResourceFilter)
|
||||
}
|
||||
aria-label={t('settings.teams.sharedList.filterLabel')}
|
||||
>
|
||||
{(['all', ...FILTER_TYPES] as ResourceFilter[]).map(
|
||||
(value) => (
|
||||
<ToggleGroupItem key={value} value={value}>
|
||||
{t(`settings.teams.sharedList.filter.${value}`)}{' '}
|
||||
{resourceCounts[value]}
|
||||
</ToggleGroupItem>
|
||||
),
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<SearchInput
|
||||
size="sm"
|
||||
className="w-full sm:w-56"
|
||||
placeholder={t('settings.teams.sharedList.search')}
|
||||
value={resourceQuery}
|
||||
onChange={(e) => setResourceQuery(e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</ListRows>
|
||||
</div>
|
||||
{visibleResources.length === 0 ? (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
title={t('settings.teams.sharedList.noMatches')}
|
||||
/>
|
||||
) : (
|
||||
<ListRows>
|
||||
{visibleResources.map((r) => {
|
||||
const isOpen = drawerOpen && openResourceKey === r.key;
|
||||
return (
|
||||
<ListRow
|
||||
key={r.key}
|
||||
interactive
|
||||
selected={isOpen}
|
||||
asChild
|
||||
leading={
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
{resourceTypeIcon(r.type)}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span title={resourceName(r)}>
|
||||
{resourceName(r)}
|
||||
</span>
|
||||
}
|
||||
description={t('settings.teams.sharedList.meta', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(r.type),
|
||||
owner: ownerLabel(r),
|
||||
})}
|
||||
trailing={
|
||||
<>
|
||||
<Badge variant="neutral" className="shrink-0">
|
||||
{resourceBadge(r)}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="shared-resource-row"
|
||||
onClick={() => openDrawerFor(r)}
|
||||
/>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListRows>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={drawerOpen && openResource !== null}
|
||||
onOpenChange={(open) => !open && closeDrawer()}
|
||||
>
|
||||
{openResource && selected && (
|
||||
<SheetContent
|
||||
side="right"
|
||||
size="detail"
|
||||
className="p-0"
|
||||
closeLabel={t('settings.teams.drawer.close')}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{/* pr-12 keeps the header clear of the close X. */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
{resourceTypeIcon(openResource.type)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<SheetTitle className="truncate">
|
||||
{resourceName(openResource)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('settings.teams.drawer.subtitle', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(openResource.type),
|
||||
owner: ownerLabel(openResource),
|
||||
})}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 px-6 pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const path = openAssetPath(openResource);
|
||||
closeDrawer();
|
||||
navigate(path);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight aria-hidden />
|
||||
{t('settings.teams.drawer.open', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(openResource.type),
|
||||
})}
|
||||
</Button>
|
||||
{callerCanShare && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
setDrawerOpen(false);
|
||||
setShareTarget(openResource);
|
||||
}}
|
||||
>
|
||||
<Users aria-hidden />
|
||||
{t('settings.teams.drawer.manageSharing')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-6 px-6 py-6">
|
||||
<DescriptionList size="sm">
|
||||
<DescriptionItem label={t('settings.teams.drawer.owner')}>
|
||||
{ownerLabel(openResource)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t('settings.teams.drawer.shared')}>
|
||||
{grantedAt(openResource)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem
|
||||
label={t('settings.teams.drawer.yourAccess')}
|
||||
>
|
||||
{callerAccessLabel(openCaller?.access)}
|
||||
</DescriptionItem>
|
||||
</DescriptionList>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.drawer.accessIn', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: selected.name,
|
||||
})}
|
||||
/>
|
||||
<Card variant="subtle" padding="none">
|
||||
<ListRows>
|
||||
{[
|
||||
...(openResource.teamGrant
|
||||
? [openResource.teamGrant]
|
||||
: []),
|
||||
...openResource.memberGrants,
|
||||
].map((g) => {
|
||||
const isTeam = !g.target_user_id;
|
||||
const label = isTeam
|
||||
? t('settings.teams.drawer.everyone', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: selected.name,
|
||||
})
|
||||
: g.target_user_label ||
|
||||
truncateSub(g.target_user_id!);
|
||||
const busy = busyGrants.has(grantKey(g));
|
||||
return (
|
||||
<ListRow
|
||||
key={grantKey(g)}
|
||||
leading={
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
size="sm"
|
||||
variant="primary"
|
||||
shape={isTeam ? 'square' : 'circle'}
|
||||
>
|
||||
{initialOf(isTeam ? selected.name : label)}
|
||||
</Avatar>
|
||||
</span>
|
||||
}
|
||||
title={<span title={label}>{label}</span>}
|
||||
description={
|
||||
isTeam
|
||||
? t('settings.teams.drawer.teamGrant')
|
||||
: t('settings.teams.drawer.memberGrant')
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
{callerCanShare ? (
|
||||
<Select
|
||||
value={g.access_level}
|
||||
disabled={busy}
|
||||
onValueChange={(value) =>
|
||||
handleGrantAccess(g, value as AccessLevel)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-28 shrink-0"
|
||||
aria-label={t(
|
||||
'settings.teams.share.access',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(['viewer', 'editor'] as const).map(
|
||||
(level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{accessLevelLabel(level)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge variant="neutral" className="shrink-0">
|
||||
{accessLevelLabel(g.access_level)}
|
||||
</Badge>
|
||||
)}
|
||||
{(callerCanShare || isAdmin) && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={busy}
|
||||
label={t(
|
||||
'settings.teams.drawer.removeGrant',
|
||||
)}
|
||||
icon={Trash2}
|
||||
onClick={() => handleUnshare(g)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ListRows>
|
||||
</Card>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.teams.drawer.otherTeamsHint')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.drawer.whatPeopleCanDo')}
|
||||
/>
|
||||
<ul className="flex flex-col gap-2 text-sm">
|
||||
{capabilityLines(
|
||||
t,
|
||||
openResource.type,
|
||||
resolveSettings(
|
||||
openResource.type,
|
||||
drawerSettings?.settings,
|
||||
),
|
||||
).map((line) => (
|
||||
<li key={line.key} className="flex items-center gap-2">
|
||||
{line.allowed ? (
|
||||
<Check
|
||||
className="text-success size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
line.allowed ? undefined : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.teams.drawer.capabilitiesHint')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
{shareTarget && (
|
||||
<ShareToTeamModal
|
||||
resourceType={shareTarget.type}
|
||||
resourceId={shareTarget.id}
|
||||
resourceName={resourceName(shareTarget)}
|
||||
onClose={() => {
|
||||
setShareTarget(null);
|
||||
// Back to the drawer, with the grants the dialog may have changed.
|
||||
setDrawerOpen(true);
|
||||
refreshGrants();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
onOpenChange={(open) =>
|
||||
@@ -923,6 +1508,7 @@ export default function Teams() {
|
||||
|
||||
<ConfirmationModal
|
||||
message={t('settings.teams.deleteTeamConfirmation', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: teamToDelete?.name ?? '',
|
||||
})}
|
||||
modalState={deleteTeamModalState}
|
||||
|
||||
@@ -393,4 +393,144 @@ describe('ToolConfig', () => {
|
||||
expect(cancel?.dataset.size).toBe('sm');
|
||||
expect(cancel?.dataset.shape).toBe('pill');
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
const viewer = { access: 'viewer', allowed_actions: ['use'] };
|
||||
const editorNoCreds = {
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit', 'use', 'use_in_own'],
|
||||
};
|
||||
const configTool = {
|
||||
...userTool,
|
||||
configRequirements: {
|
||||
token: { type: 'string', label: 'Token', secret: true, required: true },
|
||||
},
|
||||
config: { has_encrypted_credentials: true },
|
||||
} as unknown as UserToolType;
|
||||
// A disabled fieldset disables its controls in the browser; jsdom doesn't
|
||||
// apply that to `:disabled`, so check for the fieldset as well.
|
||||
const disabled = (el: Element) =>
|
||||
el.matches(':disabled') || el.closest('fieldset[disabled]') !== null;
|
||||
const nameInput = () =>
|
||||
container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.customNamePlaceholder"]',
|
||||
)!;
|
||||
const expandFirstAction = async () => {
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector('[class*="cursor-pointer"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
};
|
||||
|
||||
it('opens read-only without edit or edit_credentials: no Save, every field disabled', async () => {
|
||||
await render({ ...configTool, ...viewer } as UserToolType);
|
||||
expect(buttonByText('settings.tools.save')).toBeUndefined();
|
||||
expect(nameInput().disabled).toBe(true);
|
||||
const secret = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).find((i) => i.placeholder === '••••••••');
|
||||
expect(secret && disabled(secret)).toBe(true);
|
||||
container
|
||||
.querySelectorAll<HTMLButtonElement>('[role="switch"]')
|
||||
.forEach((sw) => expect(disabled(sw)).toBe(true));
|
||||
await expandFirstAction();
|
||||
container
|
||||
.querySelectorAll<HTMLInputElement>('table input')
|
||||
.forEach((input) => expect(disabled(input)).toBe(true));
|
||||
});
|
||||
|
||||
it('keeps the actions search usable when read-only', async () => {
|
||||
await render({ ...userTool, ...viewer } as UserToolType);
|
||||
const label = Array.from(container.querySelectorAll('label')).find(
|
||||
(el) => el.textContent === 'settings.tools.searchActions',
|
||||
)!;
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
`input[id="${label.htmlFor}"]`,
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the API tool Import and Add action buttons when read-only', async () => {
|
||||
await render({ ...apiTool, ...viewer } as APIToolType);
|
||||
expect(buttonByText('settings.tools.importSpec')).toBeUndefined();
|
||||
expect(buttonByText('settings.tools.addAction')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets an editor without edit_credentials rename but not touch credentials', async () => {
|
||||
await render({ ...configTool, ...editorNoCreds } as UserToolType);
|
||||
expect(nameInput().disabled).toBe(false);
|
||||
const authInputs = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).filter((i) => i !== nameInput() && !i.closest('table'));
|
||||
const credential = authInputs.find((i) => i.placeholder === '••••••••');
|
||||
expect(credential && disabled(credential)).toBe(true);
|
||||
});
|
||||
|
||||
it("disables an API tool's URL and header values without edit_credentials", async () => {
|
||||
await render({ ...apiTool, ...editorNoCreds } as APIToolType);
|
||||
await expandFirstAction();
|
||||
const url = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).find((i) => i.value === 'https://example.com');
|
||||
expect(url?.disabled).toBe(true);
|
||||
const headerValue = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.headerValuePlaceholder"]',
|
||||
);
|
||||
expect(headerValue?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('masks a saved API header value with a replace-to-change placeholder', async () => {
|
||||
const saved = JSON.parse(JSON.stringify(apiTool)) as APIToolType;
|
||||
saved.config.actions.list.headers.properties.Accept.has_value = true;
|
||||
await render(saved);
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector('[class*="cursor-pointer"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
const masked = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.savedSecretPlaceholder"]',
|
||||
);
|
||||
expect(masked).not.toBeNull();
|
||||
expect(masked?.type).toBe('password');
|
||||
expect(masked?.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows a non-2xx save response as a destructive Alert', async () => {
|
||||
updateTool.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
const goBack = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ToolConfig
|
||||
tool={{ ...userTool, customName: '' }}
|
||||
setTool={() => {}}
|
||||
handleGoBack={goBack}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.customNamePlaceholder"]',
|
||||
);
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set?.call(name, 'Renamed');
|
||||
name?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText('settings.tools.save')?.click();
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('[role="alert"]')?.textContent,
|
||||
).toBe('settings.tools.saveFailed');
|
||||
expect(goBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import ImportSpecModal from '../modals/ImportSpecModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { getMethodBadgeVariant } from '../utils/httpMethodColors';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { areObjectsEqual } from '../utils/objectUtils';
|
||||
import { cn, focusRing } from '@/lib/utils';
|
||||
import { APIActionType, APIToolType, UserToolType } from './types';
|
||||
@@ -52,6 +53,16 @@ const BODY_TYPE_HINT_KEYS: Record<string, string> = {
|
||||
'application/octet-stream': 'octetStream',
|
||||
};
|
||||
|
||||
/**
|
||||
* What the caller may change on the open tool (`utils/accessUtils` `can`):
|
||||
* `canEdit` covers the name and the actions, `canEditCredentials` the
|
||||
* secrets, URLs and header / query values.
|
||||
*/
|
||||
const ToolAccessContext = React.createContext({
|
||||
canEdit: true,
|
||||
canEditCredentials: true,
|
||||
});
|
||||
|
||||
/** Maps a body content type to its hint's locale key suffix (JSON by default). */
|
||||
function bodyTypeHintKey(contentType?: string): string {
|
||||
return BODY_TYPE_HINT_KEYS[contentType || 'application/json'] ?? 'json';
|
||||
@@ -108,6 +119,14 @@ export default function ToolConfig({
|
||||
Set<number>
|
||||
>(new Set());
|
||||
const { t } = useTranslation();
|
||||
const canEdit = can(tool, 'edit');
|
||||
const canEditCredentials = can(tool, 'edit_credentials');
|
||||
// Neither: the tool opens as a read-only view with no Save.
|
||||
const readOnly = !canEdit && !canEditCredentials;
|
||||
const access = React.useMemo(
|
||||
() => ({ canEdit, canEditCredentials }),
|
||||
[canEdit, canEditCredentials],
|
||||
);
|
||||
|
||||
const toggleUserActionExpand = (index: number) => {
|
||||
setExpandedUserActions((prev) => {
|
||||
@@ -245,6 +264,24 @@ export default function ToolConfig({
|
||||
});
|
||||
};
|
||||
|
||||
/** Sends the edit; a non-2xx response throws so the caller shows it. */
|
||||
const saveTool = async (configToSave: { [key: string]: any }) => {
|
||||
const response = await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response?.ok) throw new Error('Failed to save tool');
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!validateConfig()) return;
|
||||
const configToSave = buildConfigToSave();
|
||||
@@ -253,19 +290,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await saveTool(configToSave);
|
||||
setInitialState({
|
||||
customName,
|
||||
configValues: { ...configValues },
|
||||
@@ -357,16 +382,18 @@ export default function ToolConfig({
|
||||
currentLabel={tool.customName || tool.displayName || tool.name}
|
||||
onParentClick={handleBackClick}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
loading={saving}
|
||||
>
|
||||
{t('settings.tools.save')}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
loading={saving}
|
||||
>
|
||||
{t('settings.tools.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{saveError && (
|
||||
<Alert variant="destructive" className="mb-2">
|
||||
@@ -383,6 +410,7 @@ export default function ToolConfig({
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={t('settings.tools.customNamePlaceholder')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="mt-1">
|
||||
@@ -394,7 +422,10 @@ export default function ToolConfig({
|
||||
size="xs"
|
||||
title={t('settings.tools.authentication')}
|
||||
/>
|
||||
<div className="max-w-96">
|
||||
<fieldset
|
||||
disabled={!canEditCredentials}
|
||||
className="max-w-96 min-w-0"
|
||||
>
|
||||
<ConfigFields
|
||||
labelSurface="background"
|
||||
configRequirements={configRequirements}
|
||||
@@ -406,7 +437,7 @@ export default function ToolConfig({
|
||||
!!(tool as any).config?.has_encrypted_credentials
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -415,7 +446,7 @@ export default function ToolConfig({
|
||||
<SectionHeader
|
||||
title={t('settings.tools.actions')}
|
||||
actions={
|
||||
tool.name === 'api_tool' ? (
|
||||
tool.name === 'api_tool' && canEdit ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -441,7 +472,9 @@ export default function ToolConfig({
|
||||
<>
|
||||
{tool.config.actions &&
|
||||
Object.keys(tool.config.actions).length > 0 ? (
|
||||
<APIToolConfig tool={tool as APIToolType} setTool={setTool} />
|
||||
<ToolAccessContext.Provider value={access}>
|
||||
<APIToolConfig tool={tool as APIToolType} setTool={setTool} />
|
||||
</ToolAccessContext.Provider>
|
||||
) : (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
@@ -470,9 +503,10 @@ export default function ToolConfig({
|
||||
{filteredUserActions.map(({ action, originalIndex }) => {
|
||||
const isExpanded = expandedUserActions.has(originalIndex);
|
||||
return (
|
||||
<div
|
||||
<fieldset
|
||||
key={originalIndex}
|
||||
className="border-border w-full rounded-xl border"
|
||||
disabled={!canEdit}
|
||||
className="border-border w-full min-w-0 rounded-xl border"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -730,7 +764,7 @@ export default function ToolConfig({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -768,19 +802,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await saveTool(configToSave);
|
||||
setShowUnsavedModal(false);
|
||||
handleGoBack();
|
||||
} catch {
|
||||
@@ -811,6 +833,7 @@ function APIToolConfig({
|
||||
}) {
|
||||
const [apiTool, setApiTool] = React.useState<APIToolType>(tool);
|
||||
const { t } = useTranslation();
|
||||
const { canEdit, canEditCredentials } = React.useContext(ToolAccessContext);
|
||||
const [actionToDelete, setActionToDelete] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -925,9 +948,10 @@ function APIToolConfig({
|
||||
{filteredActions.map(([actionName, action], actionIndex) => {
|
||||
const isExpanded = expandedActions.has(actionName);
|
||||
return (
|
||||
<div
|
||||
<fieldset
|
||||
key={actionIndex}
|
||||
className="border-border w-full rounded-xl border"
|
||||
disabled={!canEdit}
|
||||
className="border-border w-full min-w-0 rounded-xl border"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1024,6 +1048,7 @@ function APIToolConfig({
|
||||
<Input
|
||||
type="text"
|
||||
value={action.url}
|
||||
disabled={!canEditCredentials}
|
||||
onChange={(e) => {
|
||||
setApiTool((prevApiTool) => {
|
||||
const updatedActions = {
|
||||
@@ -1213,7 +1238,7 @@ function APIToolConfig({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1249,6 +1274,7 @@ function APIActionTable({
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { canEditCredentials } = React.useContext(ToolAccessContext);
|
||||
const idPrefix = React.useId();
|
||||
|
||||
const [action, setAction] = React.useState<APIActionType>(apiAction);
|
||||
@@ -1541,10 +1567,18 @@ function APIActionTable({
|
||||
<TableCell>
|
||||
<Input
|
||||
value={param.value}
|
||||
disabled={param.filled_by_llm}
|
||||
disabled={
|
||||
param.filled_by_llm ||
|
||||
(section === 'query_params' && !canEditCredentials)
|
||||
}
|
||||
onChange={(e) =>
|
||||
handlePropertyChange(section, key, 'value', e.target.value)
|
||||
}
|
||||
{...(section === 'query_params' &&
|
||||
param.has_value && {
|
||||
type: 'password',
|
||||
placeholder: t('settings.tools.savedSecretPlaceholder'),
|
||||
})}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -1697,7 +1731,14 @@ function APIActionTable({
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t('settings.tools.headerValuePlaceholder')}
|
||||
// A saved value never comes back: empty keeps it.
|
||||
type={param.has_value ? 'password' : 'text'}
|
||||
placeholder={
|
||||
param.has_value
|
||||
? t('settings.tools.savedSecretPlaceholder')
|
||||
: t('settings.tools.headerValuePlaceholder')
|
||||
}
|
||||
disabled={!canEditCredentials}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => 'token',
|
||||
useDispatch: () => dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const { interpolation: _i, ...rest } = opts ?? {};
|
||||
void _i;
|
||||
return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../hooks')>()),
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
}));
|
||||
|
||||
// The menu is a Radix dropdown; the tests only need its options.
|
||||
vi.mock('../components/ui/dropdown-menu', () => ({
|
||||
ActionMenu: ({
|
||||
options,
|
||||
}: {
|
||||
options: { label: string; onClick: () => void }[];
|
||||
}) => (
|
||||
<div data-testid="menu">
|
||||
{options.map((o) => (
|
||||
<button key={o.label} type="button" onClick={o.onClick}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const toolConfigProps = vi.fn();
|
||||
vi.mock('./ToolConfig', () => ({
|
||||
default: (props: unknown) => {
|
||||
toolConfigProps(props);
|
||||
return <div data-testid="tool-config" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./RemoteDeviceConfig', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AddToolModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
const mcpModalProps = vi.fn();
|
||||
vi.mock('../modals/MCPServerModal', () => ({
|
||||
default: (props: unknown) => {
|
||||
mcpModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../api/services/devicesService', () => ({ default: {} }));
|
||||
|
||||
const getUserTools = vi.fn();
|
||||
const updateToolStatus = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: (...args: unknown[]) => getUserTools(...args),
|
||||
getMCPAuthStatus: () =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false }) }),
|
||||
updateToolStatus: (...args: unknown[]) => updateToolStatus(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import Tools from './Tools';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const baseTool = {
|
||||
name: 'mcp_tool',
|
||||
displayName: 'Carrier Rates MCP',
|
||||
description: 'Live rates',
|
||||
config: { server_url: 'https://mcp.example.com/sse', auth_type: 'api_key' },
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const ownTool = {
|
||||
...baseTool,
|
||||
id: 'own',
|
||||
displayName: 'own',
|
||||
status: true,
|
||||
in_chat: true,
|
||||
access: 'owner',
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_credentials',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
'use_in_own',
|
||||
],
|
||||
};
|
||||
const editorTool = {
|
||||
...baseTool,
|
||||
id: 'ed',
|
||||
displayName: 'ed',
|
||||
status: true,
|
||||
in_chat: false,
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit', 'edit_credentials', 'use', 'use_in_own'],
|
||||
shared_via: 'Logistics',
|
||||
owner_label: 'lena@example.com',
|
||||
};
|
||||
const viewerTool = {
|
||||
...baseTool,
|
||||
id: 'vw',
|
||||
displayName: 'vw',
|
||||
status: true,
|
||||
in_chat: false,
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
access: 'viewer',
|
||||
allowed_actions: ['use'],
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('Tools', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
getUserTools.mockReset();
|
||||
updateToolStatus.mockReset();
|
||||
toolConfigProps.mockReset();
|
||||
mcpModalProps.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (tools: unknown[]) => {
|
||||
getUserTools.mockImplementation(() => jsonResponse({ tools }));
|
||||
await act(async () => {
|
||||
root.render(<Tools />);
|
||||
});
|
||||
};
|
||||
|
||||
const card = (name: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('[data-slot="card"]'))
|
||||
.filter((c) => c.querySelector('[data-testid="menu"]'))
|
||||
.find((c) => c.querySelector('h2')?.textContent === name)!;
|
||||
const menuLabels = (id: string) =>
|
||||
Array.from(card(id).querySelectorAll('[data-testid="menu"] button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
const switchOf = (id: string) =>
|
||||
card(id).querySelector<HTMLButtonElement>('[role="switch"]')!;
|
||||
|
||||
it('shows Edit, Reconnect, Share and Delete to the owner', async () => {
|
||||
await render([ownTool]);
|
||||
expect(menuLabels('own')).toEqual([
|
||||
'settings.tools.edit',
|
||||
'settings.tools.reconnect',
|
||||
'settings.tools.shareWithTeam',
|
||||
'settings.tools.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows Edit and Reconnect to an editor', async () => {
|
||||
await render([editorTool]);
|
||||
expect(menuLabels('ed')).toEqual([
|
||||
'settings.tools.edit',
|
||||
'settings.tools.reconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows only View to a viewer, which opens the config read-only', async () => {
|
||||
await render([viewerTool]);
|
||||
expect(menuLabels('vw')).toEqual(['settings.tools.view']);
|
||||
await act(async () => {
|
||||
(
|
||||
card('vw').querySelector('[data-testid="menu"] button') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tool-config"]')).not.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the tool owner and role to the Reconnect modal', async () => {
|
||||
await render([editorTool]);
|
||||
const reconnect = Array.from(
|
||||
card('ed').querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="menu"] button',
|
||||
),
|
||||
).find((b) => b.textContent === 'settings.tools.reconnect')!;
|
||||
await act(async () => reconnect.click());
|
||||
const last = mcpModalProps.mock.calls.at(-1)![0] as {
|
||||
server: Record<string, unknown>;
|
||||
};
|
||||
expect(last.server).toMatchObject({
|
||||
id: 'ed',
|
||||
displayName: 'ed',
|
||||
access: 'editor',
|
||||
owner_label: 'lena@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the switch "In my chats" and binds it to in_chat', async () => {
|
||||
await render([ownTool, editorTool]);
|
||||
const sw = switchOf('ed');
|
||||
expect(sw.getAttribute('aria-checked')).toBe('false');
|
||||
expect(switchOf('own').getAttribute('aria-checked')).toBe('true');
|
||||
const label = card('ed').querySelector<HTMLLabelElement>(
|
||||
`label[for="${sw.id}"]`,
|
||||
);
|
||||
expect(label?.textContent).toBe('settings.tools.inMyChats');
|
||||
expect(sw.getAttribute('aria-label')).toBe(
|
||||
'settings.tools.useInMyChatsAria:{"toolName":"ed"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => {
|
||||
await render([viewerTool]);
|
||||
const sw = switchOf('vw');
|
||||
expect(sw.disabled).toBe(true);
|
||||
expect(card('vw').querySelector(`label[for="${sw.id}"]`)?.textContent).toBe(
|
||||
'settings.tools.inMyChats',
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts the switch and shows an error toast when the update fails', async () => {
|
||||
await render([editorTool]);
|
||||
updateToolStatus.mockImplementation(() =>
|
||||
jsonResponse({ success: false, message: 'Forbidden' }, false, 403),
|
||||
);
|
||||
await act(async () => switchOf('ed').click());
|
||||
expect(updateToolStatus).toHaveBeenCalledWith(
|
||||
{ id: 'ed', status: true },
|
||||
'token',
|
||||
);
|
||||
expect(switchOf('ed').getAttribute('aria-checked')).toBe('false');
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ variant: 'destructive' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the new value when the update succeeds', async () => {
|
||||
await render([editorTool]);
|
||||
updateToolStatus.mockImplementation(() => jsonResponse({ success: true }));
|
||||
await act(async () => switchOf('ed').click());
|
||||
expect(switchOf('ed').getAttribute('aria-checked')).toBe('true');
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
|
||||
import { Eye, Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import devicesService from '../api/services/devicesService';
|
||||
import userService from '../api/services/userService';
|
||||
@@ -12,6 +12,7 @@ import ToolIcon from '../components/ToolIcon';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardDescription, CardTitle } from '../components/ui/card';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
@@ -20,8 +21,11 @@ import AddToolModal from '../modals/AddToolModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MCPServerModal from '../modals/MCPServerModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import { can, isOwner } from '../utils/accessUtils';
|
||||
import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
|
||||
import RemoteDeviceConfig from './RemoteDeviceConfig';
|
||||
import ToolConfig from './ToolConfig';
|
||||
import { APIToolType, UserToolType } from './types';
|
||||
@@ -29,6 +33,7 @@ import { APIToolType, UserToolType } from './types';
|
||||
export default function Tools() {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [addToolModalState, setAddToolModalState] =
|
||||
@@ -81,7 +86,21 @@ export default function Tools() {
|
||||
.catch((error) => console.error('Failed to revoke device:', error));
|
||||
return;
|
||||
}
|
||||
userService.deleteTool({ id: toolToDelete.id }, token).then(afterDelete);
|
||||
userService
|
||||
.deleteTool({ id: toolToDelete.id }, token)
|
||||
.then((response: Response) => {
|
||||
if (response.ok) return afterDelete();
|
||||
setDeleteModalState('INACTIVE');
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
console.error('Failed to delete tool:', error),
|
||||
);
|
||||
};
|
||||
|
||||
const handleReconnect = (tool: UserToolType) => {
|
||||
@@ -97,41 +116,51 @@ export default function Tools() {
|
||||
timeout: config.timeout || 30,
|
||||
oauth_scopes: oauthScopes,
|
||||
has_encrypted_credentials: !!config.has_encrypted_credentials,
|
||||
access: tool.access ?? (isOwner(tool) ? 'owner' : tool.team_access),
|
||||
owner_label: tool.owner_label ?? null,
|
||||
});
|
||||
setReconnectModalState('ACTIVE');
|
||||
};
|
||||
|
||||
const getMenuOptions = (tool: UserToolType): MenuOption[] => {
|
||||
const canEdit = can(tool, 'edit') || can(tool, 'edit_credentials');
|
||||
const options: MenuOption[] = [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('settings.tools.edit'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('settings.tools.delete'),
|
||||
onClick: () => handleDeleteTool(tool),
|
||||
variant: 'destructive',
|
||||
},
|
||||
canEdit
|
||||
? {
|
||||
icon: Pencil,
|
||||
label: t('settings.tools.edit'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
}
|
||||
: {
|
||||
icon: Eye,
|
||||
label: t('settings.tools.view'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
},
|
||||
];
|
||||
// Sharing is an owner-only action: hide it for tools shared into the
|
||||
// user's workspace by a team.
|
||||
if (tool.ownership !== 'team') {
|
||||
options.splice(options.length - 1, 0, {
|
||||
if (tool.name === 'mcp_tool' && can(tool, 'edit_credentials')) {
|
||||
options.push({
|
||||
icon: RefreshCw,
|
||||
label: t('settings.tools.reconnect'),
|
||||
onClick: () => handleReconnect(tool),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
if (can(tool, 'share')) {
|
||||
options.push({
|
||||
icon: Users,
|
||||
label: t('settings.tools.shareWithTeam'),
|
||||
onClick: () => setToolToShare(tool),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
if (tool.name === 'mcp_tool') {
|
||||
options.splice(1, 0, {
|
||||
icon: RefreshCw,
|
||||
label: t('settings.tools.reconnect'),
|
||||
onClick: () => handleReconnect(tool),
|
||||
variant: 'default',
|
||||
if (can(tool, 'delete')) {
|
||||
options.push({
|
||||
icon: Trash2,
|
||||
label: t('settings.tools.delete'),
|
||||
onClick: () => handleDeleteTool(tool),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
return options;
|
||||
@@ -174,18 +203,37 @@ export default function Tools() {
|
||||
});
|
||||
};
|
||||
|
||||
const setToolInChat = (toolId: string, value: boolean) =>
|
||||
setUserTools((prevTools) =>
|
||||
prevTools.map((tool) =>
|
||||
tool.id !== toolId
|
||||
? tool
|
||||
: isOwner(tool)
|
||||
? { ...tool, status: value, in_chat: value }
|
||||
: { ...tool, in_chat: value },
|
||||
),
|
||||
);
|
||||
|
||||
// The switch moves at once and flips back when the server refuses it.
|
||||
const updateToolStatus = (toolId: string, newStatus: boolean) => {
|
||||
setToolInChat(toolId, newStatus);
|
||||
const fail = () => {
|
||||
setToolInChat(toolId, !newStatus);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.statusUpdateFailed'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
userService
|
||||
.updateToolStatus({ id: toolId, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prevTools) =>
|
||||
prevTools.map((tool) =>
|
||||
tool.id === toolId ? { ...tool, status: newStatus } : tool,
|
||||
),
|
||||
);
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) fail();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
fail();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -317,6 +365,7 @@ export default function Tools() {
|
||||
<ToolIcon
|
||||
name={tool.name}
|
||||
title={t('settings.tools.toolIconTitle', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: tool.displayName,
|
||||
})}
|
||||
className="size-6"
|
||||
@@ -372,14 +421,22 @@ export default function Tools() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<div className="absolute right-4 bottom-4 flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`toolToggle-${index}`}
|
||||
className="text-muted-foreground text-xs font-normal"
|
||||
>
|
||||
{t('settings.tools.inMyChats')}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={tool.status}
|
||||
checked={toolInChat(tool)}
|
||||
onCheckedChange={(checked) =>
|
||||
updateToolStatus(tool.id, checked)
|
||||
}
|
||||
disabled={!canAddToolToOwn(tool)}
|
||||
id={`toolToggle-${index}`}
|
||||
aria-label={t('settings.tools.toggleToolAria', {
|
||||
aria-label={t('settings.tools.useInMyChatsAria', {
|
||||
interpolation: { escapeValue: false },
|
||||
toolName: tool.customName || tool.displayName,
|
||||
})}
|
||||
/>
|
||||
@@ -401,6 +458,7 @@ export default function Tools() {
|
||||
/>
|
||||
<ConfirmationModal
|
||||
message={t('settings.tools.deleteWarning', {
|
||||
interpolation: { escapeValue: false },
|
||||
toolName:
|
||||
toolToDelete?.customName || toolToDelete?.displayName || '',
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const { dispatch, service, state } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
service: { deletePath: vi.fn() },
|
||||
state: {
|
||||
preference: {
|
||||
token: null,
|
||||
sourceDocs: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
paginatedDocuments: [{ id: 'b', name: 'B' }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => dispatch,
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: service }));
|
||||
vi.mock('../navigation/SectionShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../navigation/SectionIndexPage', () => ({ default: () => null }));
|
||||
vi.mock('./Analytics', () => ({ default: () => null }));
|
||||
vi.mock('./CustomModels', () => ({ default: () => null }));
|
||||
vi.mock('./General', () => ({ default: () => null }));
|
||||
vi.mock('./Logs', () => ({ default: () => null }));
|
||||
vi.mock('./PersonalAccessTokens', () => ({ default: () => null }));
|
||||
vi.mock('./Tools', () => ({ default: () => null }));
|
||||
// Sources: one button that deletes the only listed source.
|
||||
vi.mock('./Sources', () => ({
|
||||
default: ({
|
||||
paginatedDocuments,
|
||||
handleDeleteDocument,
|
||||
}: {
|
||||
paginatedDocuments: { id: string; name: string }[];
|
||||
handleDeleteDocument: (index: number, doc: unknown) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteDocument(0, paginatedDocuments[0])}
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Settings from './index';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('Settings source delete', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
service.deletePath.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const clickDelete = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/settings/sources']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await act(async () => container.querySelector('button')!.click());
|
||||
};
|
||||
|
||||
const actionTypes = () => dispatch.mock.calls.map(([a]) => a?.type);
|
||||
|
||||
it('a forbidden delete shows an error toast and keeps the source', async () => {
|
||||
service.deletePath.mockResolvedValue({ ok: false, status: 403 });
|
||||
await clickDelete();
|
||||
expect(service.deletePath).toHaveBeenCalledWith('b', null);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: expect.objectContaining({
|
||||
variant: 'destructive',
|
||||
message: 'settings.sources.errors.forbidden',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(actionTypes()).not.toContain('preference/setSourceDocs');
|
||||
});
|
||||
|
||||
it('a failed request shows an error toast', async () => {
|
||||
service.deletePath.mockRejectedValue(new Error('network'));
|
||||
await clickDelete();
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
message: 'settings.sources.errors.delete',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('a successful delete drops the source by id from both lists', async () => {
|
||||
service.deletePath.mockResolvedValue({ ok: true, status: 200 });
|
||||
await clickDelete();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'preference/setPaginatedDocuments',
|
||||
payload: [],
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'preference/setSourceDocs',
|
||||
payload: [{ id: 'a', name: 'A' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -6,6 +7,7 @@ import { useMediaQuery } from '../hooks';
|
||||
import { Doc } from '../models/misc';
|
||||
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { SETTINGS_SECTION } from '../navigation/sections';
|
||||
import {
|
||||
selectPaginatedDocuments,
|
||||
@@ -29,6 +31,7 @@ import Tools from './Tools';
|
||||
* `/settings` shows the destination list as page content instead.
|
||||
*/
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
@@ -39,27 +42,36 @@ export default function Settings() {
|
||||
const documents = useSelector(selectSourceDocs);
|
||||
const paginatedDocuments = useSelector(selectPaginatedDocuments);
|
||||
|
||||
const updateDocumentsList = (documents: Doc[], index: number) => [
|
||||
...documents.slice(0, index),
|
||||
...documents.slice(index + 1),
|
||||
];
|
||||
const showDeleteError = (message: string) =>
|
||||
dispatch(showActionToast({ variant: 'destructive', message }));
|
||||
|
||||
const handleDeleteClick = (index: number, doc: Doc) => {
|
||||
/**
|
||||
* Deletes a source and drops it from both lists by id. A refused or failed
|
||||
* delete (403 for a role without `delete`) shows a destructive toast and
|
||||
* leaves the lists alone.
|
||||
*/
|
||||
const handleDeleteClick = (_index: number, doc: Doc) => {
|
||||
const withoutDoc = (list: Doc[]) => list.filter((d) => d.id !== doc.id);
|
||||
userService
|
||||
.deletePath(doc.id ?? '', token)
|
||||
.then((response) => {
|
||||
if (response.ok && documents) {
|
||||
if (paginatedDocuments) {
|
||||
dispatch(
|
||||
setPaginatedDocuments(
|
||||
updateDocumentsList(paginatedDocuments, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(setSourceDocs(updateDocumentsList(documents, index)));
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) {
|
||||
showDeleteError(
|
||||
response.status === 403
|
||||
? t('settings.sources.errors.forbidden')
|
||||
: t('settings.sources.errors.delete'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (paginatedDocuments) {
|
||||
dispatch(setPaginatedDocuments(withoutDoc(paginatedDocuments)));
|
||||
}
|
||||
if (documents) dispatch(setSourceDocs(withoutDoc(documents)));
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showDeleteError(t('settings.sources.errors.delete'));
|
||||
});
|
||||
};
|
||||
|
||||
if (showIndex) {
|
||||
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user