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:
yungle246 2026-06-11 11:41:47 +09:00 committed by YungLe
parent a83118c0f4
commit be6612f454
42 changed files with 886 additions and 80 deletions

View File

@ -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>")

View File

@ -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(

View File

@ -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")

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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"""

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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} />)

View File

@ -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"

View File

@ -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>
)
}

View File

@ -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} />)

View File

@ -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" />

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "تم الإنشاء",
"apiKeyModal.generateTips": "احتفظ بهذا المفتاح في مكان آمن ويمكن الوصول إليه.",
"apiKeyModal.lastUsed": "آخر استخدام",
"apiKeyModal.scope": "النطاق",
"apiKeyModal.scopeAllDatasets": "جميع قواعد المعرفة",
"apiKeyModal.scopeBoundDataset": "قاعدة معرفة واحدة",
"apiKeyModal.scopeThisDataset": "قاعدة المعرفة هذه فقط",
"apiKeyModal.secretKey": "المفتاح السري",
"apiServer": "خادم API",
"chatMode.blocking": "نوع الحظر، في انتظار اكتمال التنفيذ وإرجاع النتائج. (قد يتم قطع الطلبات إذا كانت العملية طويلة)",

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "ایجاد شده",
"apiKeyModal.generateTips": "این کلید را در مکانی امن و قابل دسترس نگه دارید.",
"apiKeyModal.lastUsed": "آخرین استفاده",
"apiKeyModal.scope": "محدوده",
"apiKeyModal.scopeAllDatasets": "همه پایگاه‌های دانش",
"apiKeyModal.scopeBoundDataset": "یک پایگاه دانش",
"apiKeyModal.scopeThisDataset": "فقط این پایگاه دانش",
"apiKeyModal.secretKey": "کلید مخفی",
"apiServer": "سرور API",
"chatMode.blocking": "نوع مسدودکننده، منتظر اتمام اجرا و بازگشت نتایج. (درخواست‌ها ممکن است در صورت طولانی بودن فرآیند قطع شوند)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "बनाई गई",
"apiKeyModal.generateTips": "इस कुंजी को एक सुरक्षित और सुलभ स्थान पर रखें।",
"apiKeyModal.lastUsed": "अंतिम उपयोग",
"apiKeyModal.scope": "दायरा",
"apiKeyModal.scopeAllDatasets": "सभी नॉलेज बेस",
"apiKeyModal.scopeBoundDataset": "एकल नॉलेज बेस",
"apiKeyModal.scopeThisDataset": "केवल यह नॉलेज बेस",
"apiKeyModal.secretKey": "गुप्त कुंजी",
"apiServer": "एपीआई सर्वर",
"chatMode.blocking": "ब्लॉकिंग प्रकार, निष्पादन पूरा होने की प्रतीक्षा करता है और परिणाम लौटाता है। (प्रक्रिया लंबी होने पर अनुरोधों को रोका जा सकता है)",

View File

@ -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)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "作成日時",
"apiKeyModal.generateTips": "このキーを安全でアクセス可能な場所に保管してください。",
"apiKeyModal.lastUsed": "最終使用日時",
"apiKeyModal.scope": "スコープ",
"apiKeyModal.scopeAllDatasets": "すべてのナレッジベース",
"apiKeyModal.scopeBoundDataset": "単一のナレッジベース",
"apiKeyModal.scopeThisDataset": "このナレッジベースのみ",
"apiKeyModal.secretKey": "シークレットキー",
"apiServer": "API サーバー",
"chatMode.blocking": "ブロッキングタイプで、実行が完了して結果が返されるまで待機します。(処理が長い場合、リクエストは中断される場合があります)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "생성 날짜",
"apiKeyModal.generateTips": "이 키를 안전하고 접근 가능한 위치에 보관하십시오.",
"apiKeyModal.lastUsed": "최종 사용 날짜",
"apiKeyModal.scope": "범위",
"apiKeyModal.scopeAllDatasets": "모든 지식 베이스",
"apiKeyModal.scopeBoundDataset": "단일 지식 베이스",
"apiKeyModal.scopeThisDataset": "이 지식 베이스만",
"apiKeyModal.secretKey": "비밀 키",
"apiServer": "API 서버",
"chatMode.blocking": "블로킹 유형으로 실행이 완료되고 결과가 반환될 때까지 대기합니다. (처리가 오래 걸리면 요청이 중단될 수 있습니다)",

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "СОЗДАН",
"apiKeyModal.generateTips": "Храните этот ключ в безопасном и доступном месте.",
"apiKeyModal.lastUsed": "ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ",
"apiKeyModal.scope": "ОБЛАСТЬ",
"apiKeyModal.scopeAllDatasets": "Все базы знаний",
"apiKeyModal.scopeBoundDataset": "Одна база знаний",
"apiKeyModal.scopeThisDataset": "Только эта база знаний",
"apiKeyModal.secretKey": "Секретный ключ",
"apiServer": "API Сервер",
"chatMode.blocking": "Блокирующий тип, ожидает завершения выполнения и возвращает результаты. (Запросы могут быть прерваны, если процесс длительный)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "สร้าง",
"apiKeyModal.generateTips": "เก็บกุญแจนี้ไว้ในที่ปลอดภัยและเข้าถึงได้",
"apiKeyModal.lastUsed": "ใช้ล่าสุด",
"apiKeyModal.scope": "ขอบเขต",
"apiKeyModal.scopeAllDatasets": "ฐานความรู้ทั้งหมด",
"apiKeyModal.scopeBoundDataset": "ฐานความรู้เดียว",
"apiKeyModal.scopeThisDataset": "เฉพาะฐานความรู้นี้เท่านั้น",
"apiKeyModal.secretKey": "กุญแจลับ",
"apiServer": "เซิร์ฟเวอร์ API",
"chatMode.blocking": "ประเภทการบล็อก รอให้การดําเนินการเสร็จสมบูรณ์และส่งคืนผลลัพธ์ (คําขออาจถูกขัดจังหวะหากกระบวนการใช้เวลานาน)",

View File

@ -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)",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "СТВОРЕНО",
"apiKeyModal.generateTips": "Зберігайте цей ключ у безпечному та доступному місці.",
"apiKeyModal.lastUsed": "ОСТАННЄ ВИКОРИСТАННЯ",
"apiKeyModal.scope": "ОБЛАСТЬ ДІЇ",
"apiKeyModal.scopeAllDatasets": "Усі бази знань",
"apiKeyModal.scopeBoundDataset": "Одна база знань",
"apiKeyModal.scopeThisDataset": "Лише ця база знань",
"apiKeyModal.secretKey": "Секретний ключ",
"apiServer": "API сервер",
"chatMode.blocking": "Тип блокування, очікування завершення виконання та повернення результатів. (Запити можуть бути перервані, якщо процес тривалий)",

View File

@ -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)",

View File

@ -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 阻塞型,等待执行完毕后返回结果。(请求若流程较长可能会被中断)",

View File

@ -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 阻塞型,等待執行完畢後返回結果。(請求若流程較長可能會被中斷)",

View File

@ -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
}

View File

@ -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 () => {