mirror of
https://github.com/langgenius/dify.git
synced 2026-08-31 21:25:21 +08:00
feat: allow knowledge base API keys to be scoped to a single dataset
Reintroduce the nullable api_tokens.dataset_id column (dropped in 2e9819ca5b28) so dataset API keys can opt into per-knowledge-base scoping: - NULL dataset_id keeps today's workspace-wide behavior, so every existing key and the existing /datasets/api-keys create route are unchanged. - validate_dataset_token rejects a bound key for any other dataset, and for endpoints that carry no dataset id (e.g. list-all), with 403. - CachedApiToken carries dataset_id with a None default so cache entries written before deploy keep deserializing. - The per-dataset console routes in apikey.py (previously dead code that 500ed on a missing ApiToken.dataset_id) now create bound keys; their list returns bound keys plus workspace keys so the dataset page shows the full access picture. - Frontend: the knowledge base API access popover gains an API keys entry; the secret key modal accepts datasetId, shows a scope column, and offers a workspace / this-knowledge-base scope choice on create. New strings are localized for all 23 locales.
This commit is contained in:
parent
a83118c0f4
commit
be6612f454
@ -1,11 +1,12 @@
|
||||
from datetime import datetime
|
||||
from typing import override
|
||||
from uuid import UUID
|
||||
|
||||
import flask_restx
|
||||
from flask_restx import Resource
|
||||
from flask_restx._http import HTTPStatus
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
@ -34,6 +35,9 @@ class ApiKeyItem(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
token: str
|
||||
# Set only for dataset keys bound to a single knowledge base; None means the
|
||||
# key is workspace-scoped (app keys are always None).
|
||||
dataset_id: str | None = None
|
||||
last_used_at: int | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@ -218,30 +222,55 @@ class AppApiKeyResource(BaseApiKeyResource):
|
||||
|
||||
@console_ns.route("/datasets/<uuid:resource_id>/api-keys")
|
||||
class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
"""Per-dataset API keys: keys created here are bound to a single dataset.
|
||||
|
||||
Binding is stored in ``ApiToken.dataset_id`` and enforced by
|
||||
``validate_dataset_token`` (controllers/service_api/wraps.py). Workspace-scoped
|
||||
keys (NULL ``dataset_id``) are managed by ``DatasetApiKeyApi`` in
|
||||
controllers/console/datasets/datasets.py.
|
||||
"""
|
||||
|
||||
@console_ns.doc("get_dataset_api_keys")
|
||||
@console_ns.doc(description="Get all API keys for a dataset")
|
||||
@console_ns.doc(description="Get all API keys that can access a dataset")
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for a dataset"""
|
||||
"""Get all API keys that can access a dataset"""
|
||||
return dump_response(ApiKeyList, self._get_api_key_list(str(resource_id), current_tenant_id))
|
||||
|
||||
@override
|
||||
def _get_api_key_list(self, resource_id: str, current_tenant_id: str) -> ApiKeyList:
|
||||
# Unlike the app list, this returns every key that can reach the dataset:
|
||||
# keys bound to it plus the tenant's workspace-scoped (NULL dataset_id) keys,
|
||||
# so the dataset page shows the full access picture rather than a subset.
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
||||
keys = db.session.scalars(
|
||||
select(ApiToken).where(
|
||||
ApiToken.type == self.resource_type,
|
||||
ApiToken.tenant_id == current_tenant_id,
|
||||
or_(ApiToken.dataset_id == resource_id, ApiToken.dataset_id.is_(None)),
|
||||
)
|
||||
).all()
|
||||
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
|
||||
|
||||
@console_ns.doc("create_dataset_api_key")
|
||||
@console_ns.doc(description="Create a new API key for a dataset")
|
||||
@console_ns.doc(description="Create a new API key bound to a single dataset")
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
"""Create a new API key for a dataset"""
|
||||
"""Create a new API key bound to a single dataset"""
|
||||
return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201
|
||||
|
||||
resource_type = ApiTokenType.DATASET
|
||||
resource_model = Dataset
|
||||
resource_id_field = "dataset_id"
|
||||
token_prefix = "ds-"
|
||||
# Same prefix as workspace-scoped dataset keys (datasets.py); scope is carried
|
||||
# by the dataset_id column, not the token text.
|
||||
token_prefix = "dataset-"
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
|
||||
|
||||
@ -262,6 +262,14 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
except Exception:
|
||||
logger.exception("Failed to parse dataset_id from positional args")
|
||||
|
||||
# A dataset-bound token (non-NULL dataset_id) may only call endpoints that
|
||||
# carry its own dataset id; endpoints without one (e.g. list/create datasets)
|
||||
# are rejected outright. Workspace-scoped tokens (dataset_id IS NULL) keep
|
||||
# tenant-wide access, which preserves behavior for all keys created before
|
||||
# per-dataset scoping existed.
|
||||
if api_token.dataset_id and (not dataset_id or str(dataset_id) != str(api_token.dataset_id)):
|
||||
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
||||
|
||||
if dataset_id:
|
||||
dataset_id = str(dataset_id)
|
||||
dataset = db.session.scalar(
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
"""add dataset_id to api_tokens
|
||||
|
||||
Revision ID: e4f8a2c61d35
|
||||
Revises: 7bad07dc267d
|
||||
Create Date: 2026-06-10 09:00:00.000000
|
||||
|
||||
Reintroduces the nullable `dataset_id` column on `api_tokens` (it was dropped in
|
||||
2e9819ca5b28 when dataset keys became tenant-scoped) to support API keys bound to
|
||||
a single dataset/knowledge base:
|
||||
|
||||
NULL — workspace-scoped key (default; behavior of every pre-existing key).
|
||||
<uuid> — key may only access the bound dataset; enforced in
|
||||
controllers/service_api/wraps.py::validate_dataset_token.
|
||||
|
||||
No backfill is needed: NULL is the correct value for all existing rows. The column
|
||||
is nullable with no default, so this is a metadata-only change on PostgreSQL.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e4f8a2c61d35"
|
||||
down_revision = "7bad07dc267d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("api_tokens", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("dataset_id", models.types.StringUUID(), nullable=True))
|
||||
batch_op.create_index("api_token_dataset_id_idx", ["dataset_id", "type"], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("api_tokens", schema=None) as batch_op:
|
||||
batch_op.drop_index("api_token_dataset_id_idx")
|
||||
batch_op.drop_column("dataset_id")
|
||||
@ -2184,18 +2184,36 @@ class Site(Base):
|
||||
return dify_config.APP_WEB_URL or request.url_root.rstrip("/")
|
||||
|
||||
|
||||
class ApiToken(Base): # bug: this uses setattr so idk the field.
|
||||
class ApiToken(Base):
|
||||
"""API token for the service API.
|
||||
|
||||
Scoping rules:
|
||||
- ``type`` = "app": ``app_id`` points at the app the key serves.
|
||||
- ``type`` = "dataset": ``tenant_id`` is always set. ``dataset_id`` is NULL for
|
||||
workspace-scoped keys (full access to every dataset in the tenant — the default
|
||||
and the only behavior before the column was reintroduced) or set to bind the key
|
||||
to a single dataset (least-privilege keys created from a knowledge base page).
|
||||
Enforcement lives in ``validate_dataset_token`` (controllers/service_api/wraps.py);
|
||||
cached copies must mirror this field (services/api_token_service.CachedApiToken).
|
||||
|
||||
Note: controllers/console/apikey.py assigns the *_id columns via ``setattr`` keyed
|
||||
on ``resource_id_field``, so renaming ``app_id``/``dataset_id`` requires updating
|
||||
those controllers too.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="api_token_pkey"),
|
||||
sa.Index("api_token_app_id_type_idx", "app_id", "type"),
|
||||
sa.Index("api_token_token_idx", "token", "type"),
|
||||
sa.Index("api_token_tenant_idx", "tenant_id", "type"),
|
||||
sa.Index("api_token_dataset_id_idx", "dataset_id", "type"),
|
||||
)
|
||||
|
||||
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
|
||||
app_id = mapped_column(StringUUID, nullable=True)
|
||||
tenant_id = mapped_column(StringUUID, nullable=True)
|
||||
dataset_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
type: Mapped[ApiTokenType] = mapped_column(EnumText(ApiTokenType, length=16), nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
last_used_at = mapped_column(sa.DateTime, nullable=True)
|
||||
|
||||
@ -38,6 +38,9 @@ class CachedApiToken(BaseModel):
|
||||
id: str
|
||||
app_id: str | None
|
||||
tenant_id: str | None
|
||||
# Defaults to None so cache entries written before this field existed keep
|
||||
# deserializing; a validation failure here would 401 live tokens until TTL expiry.
|
||||
dataset_id: str | None = None
|
||||
type: str
|
||||
token: str
|
||||
last_used_at: datetime | None
|
||||
@ -95,6 +98,7 @@ class ApiTokenCache:
|
||||
id=str(api_token.id),
|
||||
app_id=str(api_token.app_id) if api_token.app_id else None,
|
||||
tenant_id=str(api_token.tenant_id) if api_token.tenant_id else None,
|
||||
dataset_id=str(api_token.dataset_id) if api_token.dataset_id else None,
|
||||
type=api_token.type,
|
||||
token=api_token.token,
|
||||
last_used_at=api_token.last_used_at,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@ -12,7 +13,8 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.enums import ApiTokenType
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType, DataSourceType
|
||||
from models.model import ApiToken, App, AppMode
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
@ -33,6 +35,28 @@ def setup_app(
|
||||
return test_client_with_containers, headers, app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dataset(
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> tuple[FlaskClient, dict[str, str], Dataset]:
|
||||
"""Create an authenticated client with a dataset for per-dataset API key tests."""
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant.id,
|
||||
name=f"API Key Dataset {uuid4()}",
|
||||
description="Dataset for API key scoping tests",
|
||||
data_source_type=DataSourceType.UPLOAD_FILE,
|
||||
created_by=account.id,
|
||||
permission="only_me",
|
||||
provider="vendor",
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.commit()
|
||||
headers = authenticate_console_client(test_client_with_containers, account)
|
||||
return test_client_with_containers, headers, dataset
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_api_tokens(db_session_with_containers: Session):
|
||||
"""Remove API tokens created during each test."""
|
||||
@ -184,3 +208,112 @@ class TestAppApiKeyResource:
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
BaseApiKeyResource.delete(resource, "rid", "kid", "tenant-id", non_admin)
|
||||
|
||||
|
||||
class TestDatasetApiKeyListResource:
|
||||
"""Tests for GET/POST /datasets/<resource_id>/api-keys (dataset-bound keys)."""
|
||||
|
||||
def test_create_dataset_bound_key(
|
||||
self,
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
client, headers, dataset = setup_dataset
|
||||
|
||||
resp = client.post(f"/console/api/datasets/{dataset.id}/api-keys", headers=headers)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json is not None
|
||||
assert resp.json["token"].startswith("dataset-")
|
||||
assert resp.json["dataset_id"] == dataset.id
|
||||
api_token = db_session_with_containers.scalar(select(ApiToken).where(ApiToken.id == resp.json["id"]))
|
||||
assert api_token is not None
|
||||
assert api_token.dataset_id == dataset.id
|
||||
assert api_token.tenant_id == dataset.tenant_id
|
||||
assert api_token.type == ApiTokenType.DATASET
|
||||
|
||||
def test_list_includes_bound_and_workspace_scoped_keys(
|
||||
self,
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
client, headers, dataset = setup_dataset
|
||||
|
||||
# A bound key via the per-dataset route and a workspace key via the tenant route.
|
||||
bound_resp = client.post(f"/console/api/datasets/{dataset.id}/api-keys", headers=headers)
|
||||
assert bound_resp.status_code == 201
|
||||
workspace_resp = client.post("/console/api/datasets/api-keys", headers=headers)
|
||||
assert workspace_resp.status_code == 200
|
||||
|
||||
resp = client.get(f"/console/api/datasets/{dataset.id}/api-keys", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json is not None
|
||||
scopes = {item["id"]: item["dataset_id"] for item in resp.json["data"]}
|
||||
assert bound_resp.json is not None
|
||||
assert workspace_resp.json is not None
|
||||
assert scopes[bound_resp.json["id"]] == dataset.id
|
||||
assert scopes[workspace_resp.json["id"]] is None
|
||||
|
||||
def test_list_excludes_keys_bound_to_other_datasets(
|
||||
self,
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
client, headers, dataset = setup_dataset
|
||||
other_dataset = Dataset(
|
||||
tenant_id=dataset.tenant_id,
|
||||
name=f"Other Dataset {uuid4()}",
|
||||
description="Second dataset",
|
||||
data_source_type=DataSourceType.UPLOAD_FILE,
|
||||
created_by=dataset.created_by,
|
||||
permission="only_me",
|
||||
provider="vendor",
|
||||
)
|
||||
db_session_with_containers.add(other_dataset)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
other_resp = client.post(f"/console/api/datasets/{other_dataset.id}/api-keys", headers=headers)
|
||||
assert other_resp.status_code == 201
|
||||
|
||||
resp = client.get(f"/console/api/datasets/{dataset.id}/api-keys", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json is not None
|
||||
assert other_resp.json is not None
|
||||
assert other_resp.json["id"] not in {item["id"] for item in resp.json["data"]}
|
||||
|
||||
def test_workspace_route_creates_unbound_key(
|
||||
self,
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
"""The pre-existing workspace route must keep creating NULL-scoped keys."""
|
||||
client, headers, _ = setup_dataset
|
||||
|
||||
resp = client.post("/console/api/datasets/api-keys", headers=headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json is not None
|
||||
api_token = db_session_with_containers.scalar(select(ApiToken).where(ApiToken.id == resp.json["id"]))
|
||||
assert api_token is not None
|
||||
assert api_token.dataset_id is None
|
||||
|
||||
|
||||
class TestDatasetApiKeyResource:
|
||||
"""Tests for DELETE /datasets/<resource_id>/api-keys/<api_key_id>."""
|
||||
|
||||
def test_delete_bound_key(
|
||||
self,
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
) -> None:
|
||||
client, headers, dataset = setup_dataset
|
||||
create_resp = client.post(f"/console/api/datasets/{dataset.id}/api-keys", headers=headers)
|
||||
assert create_resp.json is not None
|
||||
|
||||
resp = client.delete(
|
||||
f"/console/api/datasets/{dataset.id}/api-keys/{create_resp.json['id']}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
@ -1516,12 +1516,14 @@ class TestDatasetApiKeyApi:
|
||||
mock_key_1 = MagicMock(spec=ApiToken)
|
||||
mock_key_1.id = "key-1"
|
||||
mock_key_1.type = "dataset"
|
||||
mock_key_1.dataset_id = None
|
||||
mock_key_1.token = "ds-abc"
|
||||
mock_key_1.last_used_at = None
|
||||
mock_key_1.created_at = None
|
||||
mock_key_2 = MagicMock(spec=ApiToken)
|
||||
mock_key_2.id = "key-2"
|
||||
mock_key_2.type = "dataset"
|
||||
mock_key_2.dataset_id = None
|
||||
mock_key_2.token = "ds-def"
|
||||
mock_key_2.last_used_at = None
|
||||
mock_key_2.created_at = None
|
||||
@ -1548,6 +1550,7 @@ class TestDatasetApiKeyApi:
|
||||
|
||||
mock_token = MagicMock()
|
||||
mock_token.id = "new-key-id"
|
||||
mock_token.dataset_id = None
|
||||
mock_token.last_used_at = None
|
||||
mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
@ -9,9 +9,10 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource, DatasetApiKeyListResource
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
|
||||
@ -69,6 +70,7 @@ def test_list_api_keys_uses_injected_tenant_id() -> None:
|
||||
"id": "key-1",
|
||||
"type": "app",
|
||||
"token": "app-token",
|
||||
"dataset_id": None,
|
||||
"last_used_at": None,
|
||||
"created_at": None,
|
||||
}
|
||||
@ -106,6 +108,46 @@ def test_create_api_key_uses_injected_tenant_id() -> None:
|
||||
db_mock.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_create_dataset_api_key_binds_dataset_id() -> None:
|
||||
"""Creating a key on the per-dataset route must bind it to that dataset (ApiToken.dataset_id)."""
|
||||
resource = DatasetApiKeyListResource()
|
||||
|
||||
def add_api_token(api_token: ApiToken) -> None:
|
||||
api_token.id = "key-1"
|
||||
|
||||
with (
|
||||
patch("controllers.console.apikey._get_resource") as get_resource,
|
||||
patch("controllers.console.apikey.db") as db_mock,
|
||||
patch("controllers.console.apikey.ApiToken.generate_api_key", return_value="dataset-generated-token"),
|
||||
):
|
||||
db_mock.session.scalar.return_value = 0
|
||||
db_mock.session.add.side_effect = add_api_token
|
||||
|
||||
api_token = resource._create_api_key("dataset-1", "tenant-1")
|
||||
|
||||
get_resource.assert_called_once_with("dataset-1", "tenant-1", Dataset)
|
||||
assert api_token.dataset_id == "dataset-1"
|
||||
assert api_token.tenant_id == "tenant-1"
|
||||
assert api_token.type == ApiTokenType.DATASET
|
||||
db_mock.session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_dataset_api_key_list_includes_workspace_scoped_keys() -> None:
|
||||
"""The per-dataset key list shows everything that can reach the dataset:
|
||||
keys bound to it plus the tenant's workspace-scoped (NULL dataset_id) keys."""
|
||||
resource = DatasetApiKeyListResource()
|
||||
|
||||
with (
|
||||
patch("controllers.console.apikey._get_resource"),
|
||||
patch("controllers.console.apikey.db") as db_mock,
|
||||
):
|
||||
db_mock.session.scalars.return_value.all.return_value = []
|
||||
resource._get_api_key_list("dataset-1", "tenant-1")
|
||||
|
||||
query_sql = str(db_mock.session.scalars.call_args.args[0])
|
||||
assert "dataset_id IS NULL" in query_sql
|
||||
|
||||
|
||||
def test_delete_api_key_rejects_non_admin_account() -> None:
|
||||
resource = _make_key_resource()
|
||||
|
||||
|
||||
@ -516,6 +516,7 @@ class TestValidateDatasetToken:
|
||||
tenant_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_api_token.dataset_id = None
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
@ -554,6 +555,7 @@ class TestValidateDatasetToken:
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_api_token.dataset_id = None
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_db.session.scalar.return_value = None
|
||||
@ -568,6 +570,134 @@ class TestValidateDatasetToken:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "Dataset not found" in str(exc_info.value)
|
||||
|
||||
def _arrange_tenant_owner(self, mock_db, tenant_id: str) -> None:
|
||||
"""Stub the tenant-owner resolution shared by all success-path tests."""
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = tenant_id
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
|
||||
mock_ta = Mock()
|
||||
mock_ta.account_id = str(uuid.uuid4())
|
||||
|
||||
mock_account = Mock()
|
||||
mock_account.id = mock_ta.account_id
|
||||
mock_account.current_tenant = mock_tenant
|
||||
|
||||
setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta)
|
||||
mock_db.session.get.return_value = mock_account
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
def test_workspace_scoped_token_can_access_any_dataset(
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask
|
||||
):
|
||||
"""A token without dataset binding (NULL dataset_id) keeps tenant-wide access."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_api_token.dataset_id = None
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_dataset = Mock()
|
||||
mock_dataset.id = dataset_id
|
||||
mock_dataset.enable_api = True
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
|
||||
self._arrange_tenant_owner(mock_db, tenant_id)
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id, dataset_id=None):
|
||||
return {"success": True}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view(dataset_id=dataset_id)
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
def test_dataset_bound_token_allows_its_own_dataset(
|
||||
self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask
|
||||
):
|
||||
"""A dataset-bound token can access the dataset it is bound to."""
|
||||
# Arrange
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
dataset_id = str(uuid.uuid4())
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = tenant_id
|
||||
mock_api_token.dataset_id = dataset_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_dataset = Mock()
|
||||
mock_dataset.id = dataset_id
|
||||
mock_dataset.enable_api = True
|
||||
mock_db.session.scalar.return_value = mock_dataset
|
||||
|
||||
self._arrange_tenant_owner(mock_db, tenant_id)
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id, dataset_id=None):
|
||||
return {"success": True}
|
||||
|
||||
# Act
|
||||
with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
result = protected_view(dataset_id=dataset_id)
|
||||
|
||||
# Assert
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_dataset_bound_token_rejects_other_dataset(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""A dataset-bound token gets Forbidden for any other dataset in the tenant."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_api_token.dataset_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id, dataset_id=None):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view(dataset_id=str(uuid.uuid4()))
|
||||
assert "not authorized" in str(exc_info.value)
|
||||
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
def test_dataset_bound_token_rejects_request_without_dataset_id(self, mock_validate_token, mock_db, app: Flask):
|
||||
"""A dataset-bound token cannot call endpoints that carry no dataset id (e.g. list-all)."""
|
||||
# Arrange
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.tenant_id = str(uuid.uuid4())
|
||||
mock_api_token.dataset_id = str(uuid.uuid4())
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
@validate_dataset_token
|
||||
def protected_view(tenant_id):
|
||||
return {"success": True}
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context("/", method="GET"):
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
protected_view()
|
||||
assert "not authorized" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestFetchUserArg:
|
||||
"""Test suite for FetchUserArg model"""
|
||||
|
||||
@ -24,6 +24,7 @@ class TestApiTokenCache:
|
||||
self.mock_token.id = "test-token-id-123"
|
||||
self.mock_token.app_id = "test-app-id-456"
|
||||
self.mock_token.tenant_id = "test-tenant-id-789"
|
||||
self.mock_token.dataset_id = None
|
||||
self.mock_token.type = "app"
|
||||
self.mock_token.token = "test-token-value-abc"
|
||||
self.mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
|
||||
@ -47,6 +48,7 @@ class TestApiTokenCache:
|
||||
assert data["id"] == "test-token-id-123"
|
||||
assert data["app_id"] == "test-app-id-456"
|
||||
assert data["tenant_id"] == "test-tenant-id-789"
|
||||
assert data["dataset_id"] is None
|
||||
assert data["type"] == "app"
|
||||
assert data["token"] == "test-token-value-abc"
|
||||
assert data["last_used_at"] == "2026-02-03T10:00:00"
|
||||
@ -58,6 +60,7 @@ class TestApiTokenCache:
|
||||
mock_token.id = "test-id"
|
||||
mock_token.app_id = None
|
||||
mock_token.tenant_id = None
|
||||
mock_token.dataset_id = None
|
||||
mock_token.type = "dataset"
|
||||
mock_token.token = "test-token"
|
||||
mock_token.last_used_at = None
|
||||
@ -70,6 +73,24 @@ class TestApiTokenCache:
|
||||
assert data["tenant_id"] is None
|
||||
assert data["last_used_at"] is None
|
||||
|
||||
def test_serialize_dataset_bound_token(self):
|
||||
"""Test that a dataset-bound token round-trips its dataset_id through the cache."""
|
||||
mock_token = MagicMock()
|
||||
mock_token.id = "test-id"
|
||||
mock_token.app_id = None
|
||||
mock_token.tenant_id = "test-tenant"
|
||||
mock_token.dataset_id = "test-dataset-id-123"
|
||||
mock_token.type = "dataset"
|
||||
mock_token.token = "test-token"
|
||||
mock_token.last_used_at = None
|
||||
mock_token.created_at = datetime(2026, 1, 1, 0, 0, 0)
|
||||
|
||||
serialized = ApiTokenCache._serialize_token(mock_token)
|
||||
deserialized = ApiTokenCache._deserialize_token(serialized)
|
||||
|
||||
assert isinstance(deserialized, CachedApiToken)
|
||||
assert deserialized.dataset_id == "test-dataset-id-123"
|
||||
|
||||
def test_deserialize_token(self):
|
||||
"""Test token deserialization."""
|
||||
cached_data = json.dumps(
|
||||
@ -94,6 +115,9 @@ class TestApiTokenCache:
|
||||
assert result.token == "test-token"
|
||||
assert result.last_used_at == datetime(2026, 2, 3, 10, 0, 0)
|
||||
assert result.created_at == datetime(2026, 1, 1, 0, 0, 0)
|
||||
# Cache entries written before the dataset_id field existed must keep
|
||||
# deserializing (otherwise live tokens 401 until the cache TTL expires).
|
||||
assert result.dataset_id is None
|
||||
|
||||
def test_deserialize_null_token(self):
|
||||
"""Test deserialization of null token (cached miss)."""
|
||||
@ -218,6 +242,7 @@ class TestApiTokenCacheIntegration:
|
||||
mock_token.id = "id-123"
|
||||
mock_token.app_id = "app-456"
|
||||
mock_token.tenant_id = "tenant-789"
|
||||
mock_token.dataset_id = None
|
||||
mock_token.type = "app"
|
||||
mock_token.token = "token-abc"
|
||||
mock_token.last_used_at = datetime(2026, 2, 3, 10, 0, 0)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { DataSet, RelatedApp, RelatedAppResponse } from '@/models/datasets'
|
||||
import { Popover } from '@langgenius/dify-ui/popover'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@ -529,9 +530,12 @@ describe('ApiAccessCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
@ -539,9 +543,12 @@ describe('ApiAccessCard', () => {
|
||||
|
||||
it('should display enabled status when API is enabled', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
@ -549,9 +556,12 @@ describe('ApiAccessCard', () => {
|
||||
|
||||
it('should display disabled status when API is disabled', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
|
||||
@ -559,9 +569,12 @@ describe('ApiAccessCard', () => {
|
||||
|
||||
it('should render API Reference link', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
|
||||
@ -569,9 +582,12 @@ describe('ApiAccessCard', () => {
|
||||
|
||||
it('should render switch component', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
@ -583,9 +599,12 @@ describe('ApiAccessCard', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -600,9 +619,12 @@ describe('ApiAccessCard', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -617,9 +639,12 @@ describe('ApiAccessCard', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -635,9 +660,12 @@ describe('ApiAccessCard', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={false}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -653,9 +681,12 @@ describe('ApiAccessCard', () => {
|
||||
|
||||
it('should have correct href for API Reference link', () => {
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
|
||||
@ -668,9 +699,12 @@ describe('ApiAccessCard', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -681,9 +715,12 @@ describe('ApiAccessCard', () => {
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
|
||||
render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
@ -694,15 +731,21 @@ describe('ApiAccessCard', () => {
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
|
||||
@ -711,15 +754,21 @@ describe('ApiAccessCard', () => {
|
||||
it('should use useCallback for handlers', () => {
|
||||
// Verify handlers are stable by rendering multiple times
|
||||
const { rerender } = render(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
/>,
|
||||
<Popover open>
|
||||
<ApiAccessCard
|
||||
apiEnabled={true}
|
||||
onOpenSecretKeyModal={vi.fn()}
|
||||
/>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
// Component should render without issues with memoized callbacks
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Popover } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Card from '../card'
|
||||
@ -36,6 +37,16 @@ vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets',
|
||||
}))
|
||||
|
||||
const onOpenSecretKeyModal = vi.fn()
|
||||
|
||||
// Card renders a PopoverClose, which needs an enclosing Popover root.
|
||||
const renderCard = (apiEnabled: boolean) =>
|
||||
render(
|
||||
<Popover open>
|
||||
<Card apiEnabled={apiEnabled} onOpenSecretKeyModal={onOpenSecretKeyModal} />
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
describe('Card (API Access)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -47,33 +58,33 @@ describe('Card (API Access)', () => {
|
||||
// Rendering: verifies enabled/disabled states render correctly
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when api is enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render without crashing when api is disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
expect(screen.getByText(/serviceApi\.disabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API access tip text', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
expect(screen.getByText(/appMenus\.apiAccessTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API reference link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
|
||||
})
|
||||
|
||||
it('should render API doc text in link', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
expect(screen.getByText(/apiInfo\.doc/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API reference link in new tab', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
@ -83,13 +94,13 @@ describe('Card (API Access)', () => {
|
||||
// Props: tests enabled/disabled visual states
|
||||
describe('Props', () => {
|
||||
it('should show green indicator text when enabled', () => {
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
const enabledText = screen.getByText(/serviceApi\.enabled/)
|
||||
expect(enabledText).toHaveClass('text-text-success')
|
||||
})
|
||||
|
||||
it('should show warning text when disabled', () => {
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
const disabledText = screen.getByText(/serviceApi\.disabled/)
|
||||
expect(disabledText).toHaveClass('text-text-warning')
|
||||
})
|
||||
@ -99,7 +110,7 @@ describe('Card (API Access)', () => {
|
||||
describe('User Interactions', () => {
|
||||
it('should call enableDatasetServiceApi when toggling on', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
@ -111,7 +122,7 @@ describe('Card (API Access)', () => {
|
||||
|
||||
it('should call disableDatasetServiceApi when toggling off', async () => {
|
||||
mockDisableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
@ -123,7 +134,7 @@ describe('Card (API Access)', () => {
|
||||
|
||||
it('should call mutateDatasetRes on successful toggle', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
@ -135,7 +146,7 @@ describe('Card (API Access)', () => {
|
||||
|
||||
it('should not call mutateDatasetRes when result is not success', async () => {
|
||||
mockEnableApi.mockResolvedValue({ result: 'fail' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
@ -151,7 +162,7 @@ describe('Card (API Access)', () => {
|
||||
describe('Switch State', () => {
|
||||
it('should disable switch when user is not workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = false
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).toHaveAttribute('aria-checked', 'true')
|
||||
@ -160,19 +171,33 @@ describe('Card (API Access)', () => {
|
||||
|
||||
it('should enable switch when user is workspace manager', () => {
|
||||
mockIsCurrentWorkspaceManager = true
|
||||
render(<Card apiEnabled={true} />)
|
||||
renderCard(true)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
expect(switchButton).not.toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// API keys entry point
|
||||
describe('API Keys Button', () => {
|
||||
it('should render the API key action', () => {
|
||||
renderCard(true)
|
||||
expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onOpenSecretKeyModal when the API key action is clicked', () => {
|
||||
renderCard(true)
|
||||
fireEvent.click(screen.getByText(/serviceApi\.card\.apiKey/))
|
||||
expect(onOpenSecretKeyModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases: tests boundary scenarios
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined dataset id', async () => {
|
||||
mockDatasetId = undefined
|
||||
mockEnableApi.mockResolvedValue({ result: 'success' })
|
||||
render(<Card apiEnabled={false} />)
|
||||
renderCard(false)
|
||||
|
||||
const switchButton = screen.getByRole('switch')
|
||||
fireEvent.click(switchButton)
|
||||
|
||||
@ -20,6 +20,13 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDisableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock SecretKeyModal to avoid complex modal rendering
|
||||
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({ isShow, datasetId }: { isShow: boolean, datasetId?: string }) => (
|
||||
<div data-testid="secret-key-modal" data-show={String(isShow)} data-dataset-id={datasetId} />
|
||||
),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
@ -54,6 +61,13 @@ describe('ApiAccess', () => {
|
||||
expect((ApiAccess as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
it('should pass the dataset id from context to the secret key modal', () => {
|
||||
render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
const modal = screen.getByTestId('secret-key-modal')
|
||||
expect(modal).toHaveAttribute('data-dataset-id', 'test-dataset-id')
|
||||
expect(modal).toHaveAttribute('data-show', 'false')
|
||||
})
|
||||
|
||||
describe('toggle functionality', () => {
|
||||
it('should toggle open state when trigger is clicked', async () => {
|
||||
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { PopoverClose } from '@langgenius/dify-ui/popover'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
|
||||
import { RiArrowRightUpLine, RiBookOpenLine, RiKey2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -13,10 +14,12 @@ import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/servi
|
||||
|
||||
type CardProps = {
|
||||
apiEnabled: boolean
|
||||
onOpenSecretKeyModal: () => void
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
apiEnabled,
|
||||
onOpenSecretKeyModal,
|
||||
}: CardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
|
||||
@ -72,6 +75,20 @@ const Card = ({
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
<div className="p-1">
|
||||
<PopoverClose
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center space-x-[7px] rounded-lg border-none bg-transparent px-2 text-left text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={onOpenSecretKeyModal}
|
||||
>
|
||||
<RiKey2Line className="size-3.5 shrink-0" />
|
||||
<div className="grow truncate system-sm-regular">
|
||||
{t('serviceApi.card.apiKey', { ns: 'dataset' })}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<Link
|
||||
href={apiReferenceUrl}
|
||||
target="_blank"
|
||||
|
||||
@ -2,9 +2,11 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Card from './card'
|
||||
|
||||
type ApiAccessProps = {
|
||||
@ -18,6 +20,16 @@ const ApiAccess = ({
|
||||
}: ApiAccessProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
|
||||
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
|
||||
|
||||
const handleOpenSecretKeyModal = useCallback(() => {
|
||||
setIsSecretKeyModalVisible(true)
|
||||
}, [])
|
||||
|
||||
const handleCloseSecretKeyModal = useCallback(() => {
|
||||
setIsSecretKeyModalVisible(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="p-3 pt-2">
|
||||
@ -52,9 +64,15 @@ const ApiAccess = ({
|
||||
>
|
||||
<Card
|
||||
apiEnabled={apiEnabled}
|
||||
onOpenSecretKeyModal={handleOpenSecretKeyModal}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SecretKeyModal
|
||||
isShow={isSecretKeyModalVisible}
|
||||
datasetId={datasetId}
|
||||
onClose={handleCloseSecretKeyModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -70,6 +70,8 @@ vi.mock('@/service/use-apps', () => ({
|
||||
|
||||
const mockDatasetApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsDatasetApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockDatasetScopedApiKeysData = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsDatasetScopedApiKeysLoading = vi.fn().mockReturnValue(false)
|
||||
const mockInvalidateDatasetApiKeys = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
@ -77,6 +79,10 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
data: mockDatasetApiKeysData(),
|
||||
isLoading: mockIsDatasetApiKeysLoading(),
|
||||
}),
|
||||
useDatasetScopedApiKeys: (_datasetId: string | undefined, _options: unknown) => ({
|
||||
data: mockDatasetScopedApiKeysData(),
|
||||
isLoading: mockIsDatasetScopedApiKeysLoading(),
|
||||
}),
|
||||
useInvalidateDatasetApiKeys: () => mockInvalidateDatasetApiKeys,
|
||||
}))
|
||||
|
||||
@ -98,6 +104,8 @@ describe('SecretKeyModal', () => {
|
||||
mockIsAppApiKeysLoading.mockReturnValue(false)
|
||||
mockDatasetApiKeysData.mockReturnValue({ data: [] })
|
||||
mockIsDatasetApiKeysLoading.mockReturnValue(false)
|
||||
mockDatasetScopedApiKeysData.mockReturnValue({ data: [] })
|
||||
mockIsDatasetScopedApiKeysLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -276,6 +284,50 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a dataset-bound key by default when datasetId is provided', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} datasetId="dataset-123" />)
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDatasetApikey).toHaveBeenCalledWith({
|
||||
url: '/datasets/dataset-123/api-keys',
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create a workspace key when the workspace scope is selected', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} datasetId="dataset-123" />)
|
||||
|
||||
const workspaceScopeRadio = screen.getByText('appApi.apiKeyModal.scopeAllDatasets')
|
||||
await act(async () => {
|
||||
await user.click(workspaceScopeRadio)
|
||||
})
|
||||
|
||||
const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey')
|
||||
await act(async () => {
|
||||
await user.click(createButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDatasetApikey).toHaveBeenCalledWith({
|
||||
url: '/datasets/api-keys',
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render the scope selector without datasetId', async () => {
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
expect(screen.queryByText('appApi.apiKeyModal.scopeThisDataset')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show generate modal after creating key', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
|
||||
@ -574,6 +626,56 @@ describe('SecretKeyModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete a dataset-bound key via the per-dataset route', async () => {
|
||||
mockDatasetScopedApiKeysData.mockReturnValue({
|
||||
data: [
|
||||
{ id: 'dk-2', token: 'dataset-bound-key-123456', dataset_id: 'dataset-123', created_at: 1700000000, last_used_at: null },
|
||||
],
|
||||
})
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} datasetId="dataset-123" />)
|
||||
|
||||
const actionButtons = document.body.querySelectorAll('button.action-btn')
|
||||
const deleteButton = actionButtons[1]
|
||||
await act(async () => {
|
||||
await user.click(deleteButton!)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.actionMsg.deleteConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
await flushTransitions()
|
||||
|
||||
const confirmButton = screen.getByText('common.operation.confirm')
|
||||
await act(async () => {
|
||||
await user.click(confirmButton)
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDelDatasetApikey).toHaveBeenCalledWith({
|
||||
url: '/datasets/dataset-123/api-keys/dk-2',
|
||||
params: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the scope column for dataset keys', async () => {
|
||||
mockDatasetScopedApiKeysData.mockReturnValue({
|
||||
data: [
|
||||
{ id: 'dk-2', token: 'dataset-bound-key-123456', dataset_id: 'dataset-123', created_at: 1700000000, last_used_at: null },
|
||||
{ id: 'dk-3', token: 'dataset-workspace-key-123', dataset_id: null, created_at: 1700000000, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await renderModal(<SecretKeyModal {...defaultProps} datasetId="dataset-123" />)
|
||||
|
||||
expect(screen.getByText('appApi.apiKeyModal.scope')).toBeInTheDocument()
|
||||
// Bound key: the column label plus the selected radio option both use scopeThisDataset.
|
||||
expect(screen.getAllByText('appApi.apiKeyModal.scopeThisDataset').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getAllByText('appApi.apiKeyModal.scopeAllDatasets').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should invalidate dataset API keys after deleting', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(<SecretKeyModal {...defaultProps} />)
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Radio } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import {
|
||||
useState,
|
||||
@ -31,20 +33,26 @@ import {
|
||||
createApikey as createDatasetApikey,
|
||||
delApikey as delDatasetApikey,
|
||||
} from '@/service/datasets'
|
||||
import { useDatasetApiKeys, useInvalidateDatasetApiKeys } from '@/service/knowledge/use-dataset'
|
||||
import { useDatasetApiKeys, useDatasetScopedApiKeys, useInvalidateDatasetApiKeys } from '@/service/knowledge/use-dataset'
|
||||
import { useAppApiKeys, useInvalidateAppApiKeys } from '@/service/use-apps'
|
||||
import SecretKeyGenerateModal from './secret-key-generate'
|
||||
import s from './style.module.css'
|
||||
|
||||
type DatasetKeyScope = 'dataset' | 'workspace'
|
||||
|
||||
type ISecretKeyModalProps = {
|
||||
isShow: boolean
|
||||
appId?: string
|
||||
// When set (and appId is not), the modal manages keys for this knowledge base:
|
||||
// it lists every key that can reach it and can create keys bound to it.
|
||||
datasetId?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SecretKeyModal = ({
|
||||
isShow = false,
|
||||
appId,
|
||||
datasetId,
|
||||
onClose,
|
||||
}: ISecretKeyModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -53,12 +61,15 @@ const SecretKeyModal = ({
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [isVisible, setVisible] = useState(false)
|
||||
const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined)
|
||||
const [newKeyScope, setNewKeyScope] = useState<DatasetKeyScope>('dataset')
|
||||
const invalidateAppApiKeys = useInvalidateAppApiKeys()
|
||||
const invalidateDatasetApiKeys = useInvalidateDatasetApiKeys()
|
||||
const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { enabled: !!appId && isShow })
|
||||
const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useDatasetApiKeys({ enabled: !appId && isShow })
|
||||
const apiKeysList = appId ? appApiKeys : datasetApiKeys
|
||||
const isApiKeysLoading = appId ? isAppApiKeysLoading : isDatasetApiKeysLoading
|
||||
const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useDatasetApiKeys({ enabled: !appId && !datasetId && isShow })
|
||||
const { data: datasetScopedApiKeys, isLoading: isDatasetScopedApiKeysLoading } = useDatasetScopedApiKeys(datasetId, { enabled: !appId && isShow })
|
||||
const isDatasetScope = !appId && !!datasetId
|
||||
const apiKeysList = appId ? appApiKeys : (isDatasetScope ? datasetScopedApiKeys : datasetApiKeys)
|
||||
const isApiKeysLoading = appId ? isAppApiKeysLoading : (isDatasetScope ? isDatasetScopedApiKeysLoading : isDatasetApiKeysLoading)
|
||||
|
||||
const [delKeyID, setDelKeyId] = useState('')
|
||||
|
||||
@ -67,10 +78,14 @@ const SecretKeyModal = ({
|
||||
if (!delKeyID)
|
||||
return
|
||||
|
||||
const deletedKey = apiKeysList?.data?.find(api => api.id === delKeyID)
|
||||
const delApikey = appId ? delAppApikey : delDatasetApikey
|
||||
const params = appId
|
||||
? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} }
|
||||
: { url: `/datasets/api-keys/${delKeyID}`, params: {} }
|
||||
// Bound keys are managed on the per-dataset route; workspace keys on the tenant route.
|
||||
: deletedKey?.dataset_id
|
||||
? { url: `/datasets/${deletedKey.dataset_id}/api-keys/${delKeyID}`, params: {} }
|
||||
: { url: `/datasets/api-keys/${delKeyID}`, params: {} }
|
||||
await delApikey(params)
|
||||
if (appId)
|
||||
invalidateAppApiKeys(appId)
|
||||
@ -81,7 +96,9 @@ const SecretKeyModal = ({
|
||||
const onCreate = async () => {
|
||||
const params = appId
|
||||
? { url: `/apps/${appId}/api-keys`, body: {} }
|
||||
: { url: '/datasets/api-keys', body: {} }
|
||||
: isDatasetScope && newKeyScope === 'dataset'
|
||||
? { url: `/datasets/${datasetId}/api-keys`, body: {} }
|
||||
: { url: '/datasets/api-keys', body: {} }
|
||||
const createApikey = appId ? createAppApikey : createDatasetApikey
|
||||
const res = await createApikey(params)
|
||||
setVisible(true)
|
||||
@ -92,6 +109,14 @@ const SecretKeyModal = ({
|
||||
invalidateDatasetApiKeys()
|
||||
}
|
||||
|
||||
const getScopeLabel = (keyDatasetId?: string | null) => {
|
||||
if (!keyDatasetId)
|
||||
return t('apiKeyModal.scopeAllDatasets', { ns: 'appApi' })
|
||||
return isDatasetScope
|
||||
? t('apiKeyModal.scopeThisDataset', { ns: 'appApi' })
|
||||
: t('apiKeyModal.scopeBoundDataset', { ns: 'appApi' })
|
||||
}
|
||||
|
||||
const generateToken = (token: string) => {
|
||||
return `${token.slice(0, 3)}...${token.slice(-20)}`
|
||||
}
|
||||
@ -133,6 +158,7 @@ const SecretKeyModal = ({
|
||||
<div className="mt-4 flex grow flex-col overflow-hidden">
|
||||
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
|
||||
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div>
|
||||
{!appId && <div className="w-[180px] shrink-0 px-3">{t('apiKeyModal.scope', { ns: 'appApi' })}</div>}
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
|
||||
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
|
||||
<div className="grow px-3"></div>
|
||||
@ -141,6 +167,7 @@ const SecretKeyModal = ({
|
||||
{apiKeysList.data.map(api => (
|
||||
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}>
|
||||
<div className="w-64 shrink-0 truncate px-3 font-mono">{generateToken(api.token)}</div>
|
||||
{!appId && <div className="w-[180px] shrink-0 truncate px-3">{getScopeLabel(api.dataset_id)}</div>}
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
|
||||
<div className="w-[200px] shrink-0 truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div>
|
||||
<div className="flex grow space-x-2 px-3">
|
||||
@ -162,6 +189,22 @@ const SecretKeyModal = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{isDatasetScope && (
|
||||
<RadioGroup
|
||||
className="mt-4 flex items-center gap-4"
|
||||
value={newKeyScope}
|
||||
onValueChange={value => setNewKeyScope(value as DatasetKeyScope)}
|
||||
>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[13px] text-text-secondary">
|
||||
<Radio value="dataset" />
|
||||
{t('apiKeyModal.scopeThisDataset', { ns: 'appApi' })}
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[13px] text-text-secondary">
|
||||
<Radio value="workspace" />
|
||||
{t('apiKeyModal.scopeAllDatasets', { ns: 'appApi' })}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
<div className="flex">
|
||||
<Button className={`mt-4 flex shrink-0 ${s.autoWidth}`} onClick={onCreate} disabled={!currentWorkspace || !isCurrentWorkspaceEditor}>
|
||||
<PlusIcon className="mr-1 flex size-4 shrink-0" />
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "تم الإنشاء",
|
||||
"apiKeyModal.generateTips": "احتفظ بهذا المفتاح في مكان آمن ويمكن الوصول إليه.",
|
||||
"apiKeyModal.lastUsed": "آخر استخدام",
|
||||
"apiKeyModal.scope": "النطاق",
|
||||
"apiKeyModal.scopeAllDatasets": "جميع قواعد المعرفة",
|
||||
"apiKeyModal.scopeBoundDataset": "قاعدة معرفة واحدة",
|
||||
"apiKeyModal.scopeThisDataset": "قاعدة المعرفة هذه فقط",
|
||||
"apiKeyModal.secretKey": "المفتاح السري",
|
||||
"apiServer": "خادم API",
|
||||
"chatMode.blocking": "نوع الحظر، في انتظار اكتمال التنفيذ وإرجاع النتائج. (قد يتم قطع الطلبات إذا كانت العملية طويلة)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "ERSTELLT",
|
||||
"apiKeyModal.generateTips": "Bewahren Sie diesen Schlüssel an einem sicheren und zugänglichen Ort auf.",
|
||||
"apiKeyModal.lastUsed": "ZULETZT VERWENDET",
|
||||
"apiKeyModal.scope": "BEREICH",
|
||||
"apiKeyModal.scopeAllDatasets": "Alle Wissensdatenbanken",
|
||||
"apiKeyModal.scopeBoundDataset": "Einzelne Wissensdatenbank",
|
||||
"apiKeyModal.scopeThisDataset": "Nur diese Wissensdatenbank",
|
||||
"apiKeyModal.secretKey": "Geheimschlüssel",
|
||||
"apiServer": "API Server",
|
||||
"chatMode.blocking": "Blockierender Typ, wartet auf die Fertigstellung der Ausführung und gibt Ergebnisse zurück. (Anfragen können unterbrochen werden, wenn der Prozess lang ist)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CREATED",
|
||||
"apiKeyModal.generateTips": "Keep this key in a secure and accessible place.",
|
||||
"apiKeyModal.lastUsed": "LAST USED",
|
||||
"apiKeyModal.scope": "SCOPE",
|
||||
"apiKeyModal.scopeAllDatasets": "All knowledge bases",
|
||||
"apiKeyModal.scopeBoundDataset": "Single knowledge base",
|
||||
"apiKeyModal.scopeThisDataset": "This knowledge base only",
|
||||
"apiKeyModal.secretKey": "Secret Key",
|
||||
"apiServer": "API Server",
|
||||
"chatMode.blocking": "Blocking type, waiting for execution to complete and returning results. (Requests may be interrupted if the process is long)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CREADA",
|
||||
"apiKeyModal.generateTips": "Guarda esta clave en un lugar seguro y accesible.",
|
||||
"apiKeyModal.lastUsed": "ÚLTIMO USO",
|
||||
"apiKeyModal.scope": "ALCANCE",
|
||||
"apiKeyModal.scopeAllDatasets": "Todas las bases de conocimiento",
|
||||
"apiKeyModal.scopeBoundDataset": "Base de conocimiento única",
|
||||
"apiKeyModal.scopeThisDataset": "Solo esta base de conocimiento",
|
||||
"apiKeyModal.secretKey": "Clave secreta",
|
||||
"apiServer": "Servidor de API",
|
||||
"chatMode.blocking": "Tipo de bloqueo, esperando a que se complete la ejecución y devuelva los resultados. (Las solicitudes pueden interrumpirse si el proceso es largo)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "ایجاد شده",
|
||||
"apiKeyModal.generateTips": "این کلید را در مکانی امن و قابل دسترس نگه دارید.",
|
||||
"apiKeyModal.lastUsed": "آخرین استفاده",
|
||||
"apiKeyModal.scope": "محدوده",
|
||||
"apiKeyModal.scopeAllDatasets": "همه پایگاههای دانش",
|
||||
"apiKeyModal.scopeBoundDataset": "یک پایگاه دانش",
|
||||
"apiKeyModal.scopeThisDataset": "فقط این پایگاه دانش",
|
||||
"apiKeyModal.secretKey": "کلید مخفی",
|
||||
"apiServer": "سرور API",
|
||||
"chatMode.blocking": "نوع مسدودکننده، منتظر اتمام اجرا و بازگشت نتایج. (درخواستها ممکن است در صورت طولانی بودن فرآیند قطع شوند)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CRÉÉ",
|
||||
"apiKeyModal.generateTips": "Gardez cette clé dans un endroit sûr et accessible.",
|
||||
"apiKeyModal.lastUsed": "DERNIÈRE UTILISATION",
|
||||
"apiKeyModal.scope": "PORTÉE",
|
||||
"apiKeyModal.scopeAllDatasets": "Toutes les bases de connaissances",
|
||||
"apiKeyModal.scopeBoundDataset": "Base de connaissances unique",
|
||||
"apiKeyModal.scopeThisDataset": "Cette base de connaissances uniquement",
|
||||
"apiKeyModal.secretKey": "Clé Secrète",
|
||||
"apiServer": "Serveur API",
|
||||
"chatMode.blocking": "Type de blocage, en attente de l'exécution pour terminer et renvoyer les résultats. (Les demandes peuvent être interrompues si le processus est long)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "बनाई गई",
|
||||
"apiKeyModal.generateTips": "इस कुंजी को एक सुरक्षित और सुलभ स्थान पर रखें।",
|
||||
"apiKeyModal.lastUsed": "अंतिम उपयोग",
|
||||
"apiKeyModal.scope": "दायरा",
|
||||
"apiKeyModal.scopeAllDatasets": "सभी नॉलेज बेस",
|
||||
"apiKeyModal.scopeBoundDataset": "एकल नॉलेज बेस",
|
||||
"apiKeyModal.scopeThisDataset": "केवल यह नॉलेज बेस",
|
||||
"apiKeyModal.secretKey": "गुप्त कुंजी",
|
||||
"apiServer": "एपीआई सर्वर",
|
||||
"chatMode.blocking": "ब्लॉकिंग प्रकार, निष्पादन पूरा होने की प्रतीक्षा करता है और परिणाम लौटाता है। (प्रक्रिया लंबी होने पर अनुरोधों को रोका जा सकता है)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "DIBUAT",
|
||||
"apiKeyModal.generateTips": "Simpan kunci ini di tempat yang aman dan mudah diakses.",
|
||||
"apiKeyModal.lastUsed": "TERAKHIR DIGUNAKAN",
|
||||
"apiKeyModal.scope": "CAKUPAN",
|
||||
"apiKeyModal.scopeAllDatasets": "Semua basis pengetahuan",
|
||||
"apiKeyModal.scopeBoundDataset": "Satu basis pengetahuan",
|
||||
"apiKeyModal.scopeThisDataset": "Hanya basis pengetahuan ini",
|
||||
"apiKeyModal.secretKey": "Kunci Rahasia",
|
||||
"apiServer": "Server API",
|
||||
"chatMode.blocking": "Jenis pemblokiran, menunggu eksekusi selesai dan mengembalikan hasil. (Permintaan dapat terganggu jika prosesnya panjang)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CREATA",
|
||||
"apiKeyModal.generateTips": "Conserva questa chiave in un luogo sicuro e accessibile.",
|
||||
"apiKeyModal.lastUsed": "ULTIMO UTILIZZO",
|
||||
"apiKeyModal.scope": "AMBITO",
|
||||
"apiKeyModal.scopeAllDatasets": "Tutte le basi di conoscenza",
|
||||
"apiKeyModal.scopeBoundDataset": "Base di conoscenza singola",
|
||||
"apiKeyModal.scopeThisDataset": "Solo questa base di conoscenza",
|
||||
"apiKeyModal.secretKey": "Chiave Segreta",
|
||||
"apiServer": "Server API",
|
||||
"chatMode.blocking": "Tipo bloccante, in attesa che l'esecuzione sia completata e restituisca i risultati. (Le richieste possono essere interrotte se il processo è lungo)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "作成日時",
|
||||
"apiKeyModal.generateTips": "このキーを安全でアクセス可能な場所に保管してください。",
|
||||
"apiKeyModal.lastUsed": "最終使用日時",
|
||||
"apiKeyModal.scope": "スコープ",
|
||||
"apiKeyModal.scopeAllDatasets": "すべてのナレッジベース",
|
||||
"apiKeyModal.scopeBoundDataset": "単一のナレッジベース",
|
||||
"apiKeyModal.scopeThisDataset": "このナレッジベースのみ",
|
||||
"apiKeyModal.secretKey": "シークレットキー",
|
||||
"apiServer": "API サーバー",
|
||||
"chatMode.blocking": "ブロッキングタイプで、実行が完了して結果が返されるまで待機します。(処理が長い場合、リクエストは中断される場合があります)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "생성 날짜",
|
||||
"apiKeyModal.generateTips": "이 키를 안전하고 접근 가능한 위치에 보관하십시오.",
|
||||
"apiKeyModal.lastUsed": "최종 사용 날짜",
|
||||
"apiKeyModal.scope": "범위",
|
||||
"apiKeyModal.scopeAllDatasets": "모든 지식 베이스",
|
||||
"apiKeyModal.scopeBoundDataset": "단일 지식 베이스",
|
||||
"apiKeyModal.scopeThisDataset": "이 지식 베이스만",
|
||||
"apiKeyModal.secretKey": "비밀 키",
|
||||
"apiServer": "API 서버",
|
||||
"chatMode.blocking": "블로킹 유형으로 실행이 완료되고 결과가 반환될 때까지 대기합니다. (처리가 오래 걸리면 요청이 중단될 수 있습니다)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CREATED",
|
||||
"apiKeyModal.generateTips": "Keep this key in a secure and accessible place.",
|
||||
"apiKeyModal.lastUsed": "LAST USED",
|
||||
"apiKeyModal.scope": "BEREIK",
|
||||
"apiKeyModal.scopeAllDatasets": "Alle kennisbanken",
|
||||
"apiKeyModal.scopeBoundDataset": "Eén kennisbank",
|
||||
"apiKeyModal.scopeThisDataset": "Alleen deze kennisbank",
|
||||
"apiKeyModal.secretKey": "Secret Key",
|
||||
"apiServer": "API Server",
|
||||
"chatMode.blocking": "Blocking type, waiting for execution to complete and returning results. (Requests may be interrupted if the process is long)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "UTWORZONY",
|
||||
"apiKeyModal.generateTips": "Przechowuj ten klucz w bezpiecznym i dostępnym miejscu.",
|
||||
"apiKeyModal.lastUsed": "OSTATNIO UŻYWANY",
|
||||
"apiKeyModal.scope": "ZAKRES",
|
||||
"apiKeyModal.scopeAllDatasets": "Wszystkie bazy wiedzy",
|
||||
"apiKeyModal.scopeBoundDataset": "Pojedyncza baza wiedzy",
|
||||
"apiKeyModal.scopeThisDataset": "Tylko ta baza wiedzy",
|
||||
"apiKeyModal.secretKey": "Tajny Klucz",
|
||||
"apiServer": "Serwer API",
|
||||
"chatMode.blocking": "Typ blokujący, czekanie na zakończenie wykonania i zwrócenie wyników. (Żądania mogą być przerywane, jeśli proces jest długi)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CRIADA",
|
||||
"apiKeyModal.generateTips": "Mantenha esta chave em um local seguro e acessível.",
|
||||
"apiKeyModal.lastUsed": "ÚLTIMO USO",
|
||||
"apiKeyModal.scope": "ESCOPO",
|
||||
"apiKeyModal.scopeAllDatasets": "Todas as bases de conhecimento",
|
||||
"apiKeyModal.scopeBoundDataset": "Base de conhecimento única",
|
||||
"apiKeyModal.scopeThisDataset": "Apenas esta base de conhecimento",
|
||||
"apiKeyModal.secretKey": "Chave Secreta",
|
||||
"apiServer": "Servidor da API",
|
||||
"chatMode.blocking": "Tipo de bloqueio, aguardando a conclusão da execução e retornando os resultados. (As solicitações podem ser interrompidas se o processo for longo)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "CREATĂ",
|
||||
"apiKeyModal.generateTips": "Păstrați această cheie într-un loc sigur și accesibil.",
|
||||
"apiKeyModal.lastUsed": "ULTIMA UTILIZARE",
|
||||
"apiKeyModal.scope": "DOMENIU",
|
||||
"apiKeyModal.scopeAllDatasets": "Toate bazele de cunoștințe",
|
||||
"apiKeyModal.scopeBoundDataset": "O singură bază de cunoștințe",
|
||||
"apiKeyModal.scopeThisDataset": "Doar această bază de cunoștințe",
|
||||
"apiKeyModal.secretKey": "Cheie Secretă",
|
||||
"apiServer": "Server API",
|
||||
"chatMode.blocking": "Tip blocant, așteptând finalizarea execuției și returnarea rezultatelor. (Cererea poate fi întreruptă dacă procesul este lung)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "СОЗДАН",
|
||||
"apiKeyModal.generateTips": "Храните этот ключ в безопасном и доступном месте.",
|
||||
"apiKeyModal.lastUsed": "ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ",
|
||||
"apiKeyModal.scope": "ОБЛАСТЬ",
|
||||
"apiKeyModal.scopeAllDatasets": "Все базы знаний",
|
||||
"apiKeyModal.scopeBoundDataset": "Одна база знаний",
|
||||
"apiKeyModal.scopeThisDataset": "Только эта база знаний",
|
||||
"apiKeyModal.secretKey": "Секретный ключ",
|
||||
"apiServer": "API Сервер",
|
||||
"chatMode.blocking": "Блокирующий тип, ожидает завершения выполнения и возвращает результаты. (Запросы могут быть прерваны, если процесс длительный)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "USTVARJENO",
|
||||
"apiKeyModal.generateTips": "Hranite ta ključ na varnem in dostopnem mestu.",
|
||||
"apiKeyModal.lastUsed": "ZADNJA UPORABA",
|
||||
"apiKeyModal.scope": "OBSEG",
|
||||
"apiKeyModal.scopeAllDatasets": "Vse baze znanja",
|
||||
"apiKeyModal.scopeBoundDataset": "Ena baza znanja",
|
||||
"apiKeyModal.scopeThisDataset": "Samo ta baza znanja",
|
||||
"apiKeyModal.secretKey": "Skrivni ključ",
|
||||
"apiServer": "API Strežnik",
|
||||
"chatMode.blocking": "Vrsta blokiranja, čakanje na dokončanje izvajanja in vračanje rezultatov. (Zahteve se lahko prekinejo, če je postopek dolg)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "สร้าง",
|
||||
"apiKeyModal.generateTips": "เก็บกุญแจนี้ไว้ในที่ปลอดภัยและเข้าถึงได้",
|
||||
"apiKeyModal.lastUsed": "ใช้ล่าสุด",
|
||||
"apiKeyModal.scope": "ขอบเขต",
|
||||
"apiKeyModal.scopeAllDatasets": "ฐานความรู้ทั้งหมด",
|
||||
"apiKeyModal.scopeBoundDataset": "ฐานความรู้เดียว",
|
||||
"apiKeyModal.scopeThisDataset": "เฉพาะฐานความรู้นี้เท่านั้น",
|
||||
"apiKeyModal.secretKey": "กุญแจลับ",
|
||||
"apiServer": "เซิร์ฟเวอร์ API",
|
||||
"chatMode.blocking": "ประเภทการบล็อก รอให้การดําเนินการเสร็จสมบูรณ์และส่งคืนผลลัพธ์ (คําขออาจถูกขัดจังหวะหากกระบวนการใช้เวลานาน)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "OLUŞTURULDU",
|
||||
"apiKeyModal.generateTips": "Bu anahtarı güvenli ve erişilebilir bir yerde saklayın.",
|
||||
"apiKeyModal.lastUsed": "SON KULLANIM",
|
||||
"apiKeyModal.scope": "KAPSAM",
|
||||
"apiKeyModal.scopeAllDatasets": "Tüm bilgi tabanları",
|
||||
"apiKeyModal.scopeBoundDataset": "Tek bilgi tabanı",
|
||||
"apiKeyModal.scopeThisDataset": "Yalnızca bu bilgi tabanı",
|
||||
"apiKeyModal.secretKey": "Gizli Anahtar",
|
||||
"apiServer": "API Sunucusu",
|
||||
"chatMode.blocking": "Bloke etme tipi, yürütmenin tamamlanmasını bekleyip sonuçları döndürme. (Süreç uzun sürerse istekler kesilebilir)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "СТВОРЕНО",
|
||||
"apiKeyModal.generateTips": "Зберігайте цей ключ у безпечному та доступному місці.",
|
||||
"apiKeyModal.lastUsed": "ОСТАННЄ ВИКОРИСТАННЯ",
|
||||
"apiKeyModal.scope": "ОБЛАСТЬ ДІЇ",
|
||||
"apiKeyModal.scopeAllDatasets": "Усі бази знань",
|
||||
"apiKeyModal.scopeBoundDataset": "Одна база знань",
|
||||
"apiKeyModal.scopeThisDataset": "Лише ця база знань",
|
||||
"apiKeyModal.secretKey": "Секретний ключ",
|
||||
"apiServer": "API сервер",
|
||||
"chatMode.blocking": "Тип блокування, очікування завершення виконання та повернення результатів. (Запити можуть бути перервані, якщо процес тривалий)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "ĐÃ TẠO",
|
||||
"apiKeyModal.generateTips": "Hãy lưu giữ khóa này ở nơi an toàn và dễ tiếp cận.",
|
||||
"apiKeyModal.lastUsed": "SỬ DỤNG LẦN CUỐI",
|
||||
"apiKeyModal.scope": "PHẠM VI",
|
||||
"apiKeyModal.scopeAllDatasets": "Tất cả cơ sở tri thức",
|
||||
"apiKeyModal.scopeBoundDataset": "Một cơ sở tri thức",
|
||||
"apiKeyModal.scopeThisDataset": "Chỉ cơ sở tri thức này",
|
||||
"apiKeyModal.secretKey": "Khóa bí mật",
|
||||
"apiServer": "Máy chủ API",
|
||||
"chatMode.blocking": "Loại chặn, đợi để thực hiện hoàn tất và trả kết quả. (Yêu cầu có thể bị gián đoạn nếu quá trình kéo dài)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "创建时间",
|
||||
"apiKeyModal.generateTips": "请将此密钥保存在安全且可访问的地方。",
|
||||
"apiKeyModal.lastUsed": "最后使用",
|
||||
"apiKeyModal.scope": "范围",
|
||||
"apiKeyModal.scopeAllDatasets": "所有知识库",
|
||||
"apiKeyModal.scopeBoundDataset": "单个知识库",
|
||||
"apiKeyModal.scopeThisDataset": "仅此知识库",
|
||||
"apiKeyModal.secretKey": "密钥",
|
||||
"apiServer": "API 服务器",
|
||||
"chatMode.blocking": "blocking 阻塞型,等待执行完毕后返回结果。(请求若流程较长可能会被中断)",
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
"apiKeyModal.created": "建立時間",
|
||||
"apiKeyModal.generateTips": "請將此金鑰儲存在安全且可訪問的地方。",
|
||||
"apiKeyModal.lastUsed": "最後使用",
|
||||
"apiKeyModal.scope": "範圍",
|
||||
"apiKeyModal.scopeAllDatasets": "所有知識庫",
|
||||
"apiKeyModal.scopeBoundDataset": "單一知識庫",
|
||||
"apiKeyModal.scopeThisDataset": "僅此知識庫",
|
||||
"apiKeyModal.secretKey": "金鑰",
|
||||
"apiServer": "API 伺服器",
|
||||
"chatMode.blocking": "blocking 阻塞型,等待執行完畢後返回結果。(請求若流程較長可能會被中斷)",
|
||||
|
||||
@ -78,6 +78,8 @@ export type UpdateAppModelConfigResponse = { result: string }
|
||||
type ApiKeyItemResponse = {
|
||||
id: string
|
||||
token: string
|
||||
/** Dataset keys only: the bound dataset id, or null for workspace-scoped keys. */
|
||||
dataset_id?: string | null
|
||||
last_used_at: string
|
||||
created_at: string
|
||||
}
|
||||
@ -89,6 +91,7 @@ export type ApiKeysListResponse = {
|
||||
export type CreateApiKeyResponse = {
|
||||
id: string
|
||||
token: string
|
||||
dataset_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@ -178,6 +178,17 @@ export const useDatasetApiKeys = (options?: { enabled?: boolean }) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Keys that can access one dataset: keys bound to it plus workspace-scoped keys.
|
||||
export const useDatasetScopedApiKeys = (datasetId?: string, options?: { enabled?: boolean }) => {
|
||||
return useQuery<ApiKeysListResponse>({
|
||||
queryKey: [NAME_SPACE, 'api-keys', datasetId],
|
||||
queryFn: () => get<ApiKeysListResponse>(`/datasets/${datasetId}/api-keys`),
|
||||
enabled: !!datasetId && (options?.enabled ?? true),
|
||||
})
|
||||
}
|
||||
|
||||
// Prefix-matches both the workspace list ([NAME_SPACE, 'api-keys']) and every
|
||||
// per-dataset list ([NAME_SPACE, 'api-keys', datasetId]).
|
||||
export const useInvalidateDatasetApiKeys = () => {
|
||||
const queryClient = useQueryClient()
|
||||
return () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user