This commit is contained in:
YungLe 2026-06-26 04:23:48 +09:00 committed by GitHub
commit af38ab8ed9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1009 additions and 138 deletions

View File

@ -1,11 +1,13 @@
from collections.abc import Iterable
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
@ -38,6 +40,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
@ -54,6 +59,28 @@ class ApiKeyList(ResponseModel):
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
def mask_api_token(token: str) -> str:
"""Mask a secret token for list responses.
Reveal-once: the full secret is only returned by the create endpoint. List
endpoints expose just enough (prefix + last 4) to identify a key, never the
full value, so an existing key's secret cannot be retrieved after creation.
"""
if len(token) <= 8:
return "***"
return f"{token[:5]}...{token[-4:]}"
def build_masked_api_key_list(api_tokens: Iterable[ApiToken]) -> ApiKeyList:
"""Build an ApiKeyList from ORM tokens with their secrets masked."""
items: list[ApiKeyItem] = []
for api_token in api_tokens:
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
item.token = mask_api_token(item.token)
items.append(item)
return ApiKeyList(data=items)
def _get_resource(resource_id, tenant_id, resource_model):
with sessionmaker(db.engine).begin() as session:
resource = session.execute(
@ -87,7 +114,7 @@ class BaseApiKeyListResource(Resource):
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
return build_masked_api_key_list(keys)
@edit_permission_required
def post(self, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
@ -224,17 +251,40 @@ 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 build_masked_api_key_list(keys)
@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")
@ -242,13 +292,15 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE)
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

@ -13,7 +13,7 @@ from configs import dify_config
from controllers.common.fields import ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.apikey import ApiKeyItem, ApiKeyList
from controllers.console.apikey import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
from controllers.console.wraps import (
@ -1007,7 +1007,7 @@ class DatasetApiKeyApi(Resource):
keys = db.session.scalars(
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json")
return build_masked_api_key_list(keys).model_dump(mode="json")
@console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")

View File

@ -305,6 +305,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: d9e8f7a6b5c4
Create Date: 2026-06-17 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 = "d9e8f7a6b5c4"
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

@ -2226,18 +2226,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

@ -6060,7 +6060,7 @@ Check if dataset is in use
| 200 | Dataset use status retrieved successfully | **application/json**: [UsageCheckResponse](#usagecheckresponse)<br> |
### [GET] /datasets/{resource_id}/api-keys
**Get all API keys for a dataset**
**Get all API keys that can access a dataset**
#### Parameters
@ -6075,7 +6075,7 @@ Check if dataset is in use
| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)<br> |
### [POST] /datasets/{resource_id}/api-keys
**Create a new API key for a dataset**
**Create a new API key bound to a single dataset**
#### Parameters
@ -13647,6 +13647,7 @@ Soft lifecycle state for Agent records.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | No |
| dataset_id | string | | No |
| id | string | | Yes |
| last_used_at | integer | | No |
| token | string | | Yes |

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,117 @@ 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
# Capture ids up front: the commit below expires these instances and the
# subsequent request teardown detaches them, so reading dataset.id afterwards
# would raise DetachedInstanceError.
dataset_id = dataset.id
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_dataset_id = other_dataset.id
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

@ -1758,13 +1758,15 @@ class TestDatasetApiKeyApi:
mock_key_1 = MagicMock(spec=ApiToken)
mock_key_1.id = "key-1"
mock_key_1.type = "dataset"
mock_key_1.token = "ds-abc"
mock_key_1.dataset_id = None
mock_key_1.token = "dataset-aaaa1111bbbb"
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.token = "ds-def"
mock_key_2.dataset_id = None
mock_key_2.token = "dataset-cccc2222dddd"
mock_key_2.last_used_at = None
mock_key_2.created_at = None
@ -1779,10 +1781,11 @@ class TestDatasetApiKeyApi:
assert "data" in response
assert len(response["data"]) == 2
# reveal-once: the list returns masked tokens, never the full secret
assert response["data"][0]["id"] == "key-1"
assert response["data"][0]["token"] == "ds-abc"
assert response["data"][0]["token"] == "datas...bbbb"
assert response["data"][1]["id"] == "key-2"
assert response["data"][1]["token"] == "ds-def"
assert response["data"][1]["token"] == "datas...dddd"
def test_post_create_api_key_success(self, app: Flask):
api = DatasetApiKeyApi()
@ -1790,6 +1793,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,16 @@ 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,
build_masked_api_key_list,
mask_api_token,
)
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
@ -44,12 +51,37 @@ def _make_account(role: TenantAccountRole) -> Account:
return account
def test_mask_api_token_reveals_only_a_fragment() -> None:
# full secret must never be reproducible from the masked value
masked = mask_api_token("dataset-mqxAkpML14jRmgsb6Z7DBnVq")
assert masked == "datas...BnVq"
assert "mqxAkpML" not in masked
# very short tokens are fully hidden
assert mask_api_token("short") == "***"
def test_build_masked_api_key_list_masks_every_token() -> None:
keys = [
SimpleNamespace(
id="key-1",
type=ApiTokenType.DATASET,
token="dataset-aaaabbbbccccdddd",
dataset_id="ds-1",
last_used_at=None,
created_at=None,
),
]
result = build_masked_api_key_list(keys)
assert result.data[0].token == "datas...dddd"
assert result.data[0].dataset_id == "ds-1"
def test_list_api_keys_uses_injected_tenant_id() -> None:
resource = _make_list_resource()
api_key = SimpleNamespace(
id="key-1",
type=ApiTokenType.APP,
token="app-token",
token="app-1234567890abcdef",
last_used_at=None,
created_at=None,
)
@ -67,8 +99,10 @@ def test_list_api_keys_uses_injected_tenant_id() -> None:
"data": [
{
"id": "key-1",
# reveal-once: the list never returns the full secret, only a masked fragment
"type": "app",
"token": "app-token",
"token": "app-1...cdef",
"dataset_id": None,
"last_used_at": None,
"created_at": None,
}
@ -106,6 +140,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

@ -3290,14 +3290,6 @@
"count": 2
}
},
"web/app/components/develop/secret-key/secret-key-modal.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1

View File

@ -101,6 +101,7 @@ export type ApiKeyList = {
export type ApiKeyItem = {
created_at?: number | null
dataset_id?: string | null
id: string
last_used_at?: number | null
token: string

View File

@ -34,6 +34,7 @@ export const zAgentApiStatusPayload = z.object({
*/
export const zApiKeyItem = z.object({
created_at: z.int().nullish(),
dataset_id: z.string().nullish(),
id: z.string(),
last_used_at: z.int().nullish(),
token: z.string(),

View File

@ -1179,6 +1179,7 @@ export type ApiKeyList = {
export type ApiKeyItem = {
created_at?: number | null
dataset_id?: string | null
id: string
last_used_at?: number | null
token: string

View File

@ -772,6 +772,7 @@ export const zWorkflowRestoreResponse = z.object({
*/
export const zApiKeyItem = z.object({
created_at: z.int().nullish(),
dataset_id: z.string().nullish(),
id: z.string(),
last_used_at: z.int().nullish(),
token: z.string(),

View File

@ -1712,37 +1712,37 @@ export const byApiKeyId2 = {
}
/**
* Get all API keys for a dataset
* Get all API keys that can access a dataset
*
* Get all API keys for a dataset
* Get all API keys that can access a dataset
*/
export const get35 = oc
.route({
description: 'Get all API keys for a dataset',
description: 'Get all API keys that can access a dataset',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getDatasetsByResourceIdApiKeys',
path: '/datasets/{resource_id}/api-keys',
summary: 'Get all API keys for a dataset',
summary: 'Get all API keys that can access a dataset',
tags: ['console'],
})
.input(z.object({ params: zGetDatasetsByResourceIdApiKeysPath }))
.output(zGetDatasetsByResourceIdApiKeysResponse)
/**
* Create a new API key for a dataset
* Create a new API key bound to a single dataset
*
* Create a new API key for a dataset
* Create a new API key bound to a single dataset
*/
export const post22 = oc
.route({
description: 'Create a new API key for a dataset',
description: 'Create a new API key bound to a single dataset',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postDatasetsByResourceIdApiKeys',
path: '/datasets/{resource_id}/api-keys',
successStatus: 201,
summary: 'Create a new API key for a dataset',
summary: 'Create a new API key bound to a single dataset',
tags: ['console'],
})
.input(z.object({ params: zPostDatasetsByResourceIdApiKeysPath }))

View File

@ -72,6 +72,7 @@ export type ApiKeyList = {
export type ApiKeyItem = {
created_at?: number | null
dataset_id?: string | null
id: string
last_used_at?: number | null
token: string

View File

@ -14,6 +14,7 @@ export const zApiBaseUrlResponse = z.object({
*/
export const zApiKeyItem = z.object({
created_at: z.int().nullish(),
dataset_id: z.string().nullish(),
id: z.string(),
last_used_at: z.int().nullish(),
token: z.string(),

View File

@ -69,6 +69,7 @@ vi.mock('@/service/use-apps', () => ({
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useDatasetScopedApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))

View File

@ -84,6 +84,7 @@ vi.mock('@/service/use-apps', () => ({
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
useDatasetScopedApiKeys: () => ({ data: null, isLoading: false }),
useInvalidateDatasetApiKeys: () => vi.fn(),
}))

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'
@ -523,9 +524,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()
@ -533,9 +537,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()
@ -543,9 +550,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()
@ -553,9 +563,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()
@ -563,9 +576,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()
@ -577,9 +593,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')
@ -594,9 +613,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')
@ -611,9 +633,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')
@ -629,9 +654,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')
@ -647,9 +675,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')
@ -662,9 +693,12 @@ describe('ApiAccessCard', () => {
mockDataset.permission_keys = []
render(
<ApiAccessCard
apiEnabled={true}
/>,
<Popover open>
<ApiAccessCard
apiEnabled={true}
onOpenSecretKeyModal={vi.fn()}
/>
</Popover>,
)
const switchButton = screen.getByRole('switch')
@ -675,9 +709,12 @@ describe('ApiAccessCard', () => {
mockDataset.permission_keys = [DatasetACLPermission.Edit]
render(
<ApiAccessCard
apiEnabled={true}
/>,
<Popover open>
<ApiAccessCard
apiEnabled={true}
onOpenSecretKeyModal={vi.fn()}
/>
</Popover>,
)
const switchButton = screen.getByRole('switch')
@ -688,15 +725,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()
@ -705,15 +748,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 { DatasetACLPermission } from '@/utils/permission'
@ -32,6 +33,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()
@ -43,33 +54,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')
@ -79,13 +90,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')
})
@ -95,7 +106,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)
@ -107,7 +118,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)
@ -119,7 +130,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)
@ -131,7 +142,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)
@ -147,7 +158,7 @@ describe('Card (API Access)', () => {
describe('Switch State', () => {
it('should disable switch when dataset lacks edit ACL permission', () => {
mockDatasetPermissionKeys = []
render(<Card apiEnabled={true} />)
renderCard(true)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveAttribute('aria-checked', 'true')
@ -156,19 +167,33 @@ describe('Card (API Access)', () => {
it('should enable switch when dataset has edit ACL permission', () => {
mockDatasetPermissionKeys = [DatasetACLPermission.Edit]
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

@ -4,11 +4,13 @@ import ApiAccess from '../index'
// Mock context and hooks for Card component
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn(() => 'test-dataset-id'),
useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ dataset: { id: 'test-dataset-id', permission_keys: [] }, mutateDatasetRes: () => {} }),
}))
vi.mock('@/context/app-context', () => ({
useSelector: vi.fn(() => true),
useSelector: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ workspacePermissionKeys: ['dataset.api_key.manage'], userProfile: { id: 'u1' } }),
}))
vi.mock('@/hooks/use-api-access-url', () => ({
@ -20,6 +22,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 +63,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,4 +1,5 @@
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 * as React from 'react'
@ -13,10 +14,12 @@ import { getDatasetACLCapabilities } from '@/utils/permission'
type CardProps = {
apiEnabled: boolean
onOpenSecretKeyModal: () => void
}
const Card = ({
apiEnabled,
onOpenSecretKeyModal,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
@ -83,6 +86,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}
>
<span className="i-ri-key-2-line 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,13 @@ 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 { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { hasPermission } from '@/utils/permission'
import Card from './card'
type ApiAccessProps = {
@ -18,6 +22,18 @@ const ApiAccess = ({
}: ApiAccessProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageSecretKey = hasPermission(workspacePermissionKeys, 'dataset.api_key.manage')
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
const handleCloseSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(false)
}, [])
return (
<div className={cn(expand ? 'px-1 py-2' : 'flex justify-center px-3 py-2')}>
@ -52,9 +68,16 @@ const ApiAccess = ({
>
<Card
apiEnabled={apiEnabled}
onOpenSecretKeyModal={handleOpenSecretKeyModal}
/>
</PopoverContent>
</Popover>
<SecretKeyModal
isShow={isSecretKeyModalVisible}
datasetId={datasetId}
canManage={canManageSecretKey}
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,
}))
@ -99,6 +105,8 @@ describe('SecretKeyModal', () => {
mockIsAppApiKeysLoading.mockReturnValue(false)
mockDatasetApiKeysData.mockReturnValue({ data: [] })
mockIsDatasetApiKeysLoading.mockReturnValue(false)
mockDatasetScopedApiKeysData.mockReturnValue({ data: [] })
mockIsDatasetScopedApiKeysLoading.mockReturnValue(false)
})
afterEach(() => {
@ -169,7 +177,7 @@ describe('SecretKeyModal', () => {
it('should render API keys when available', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument()
})
it('should render created time for keys', async () => {
@ -228,7 +236,7 @@ describe('SecretKeyModal', () => {
it('should render dataset API keys when no appId', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('dk-abc123def456ghi789')).toBeInTheDocument()
})
})
@ -284,6 +292,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" />)
@ -393,14 +445,14 @@ describe('SecretKeyModal', () => {
it('should render delete button for permitted users', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = screen.getAllByRole('button')
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
const actionButtons = document.body.querySelectorAll('button.action-btn')
expect(actionButtons.length).toBeGreaterThanOrEqual(1)
})
it('should render API key row with actions', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument()
})
it('should have action buttons in the key row', async () => {
@ -423,7 +475,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
expect(deleteButton).toBeInTheDocument()
await act(async () => {
@ -443,7 +495,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -473,7 +525,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -500,7 +552,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -529,7 +581,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -565,7 +617,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -590,12 +642,62 @@ 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[0]
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} />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -618,16 +720,16 @@ describe('SecretKeyModal', () => {
})
})
describe('token truncation', () => {
it('should truncate token correctly', async () => {
describe('token display', () => {
it('should display the token exactly as provided by the API (no client-side reveal)', async () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
{ id: 'key-1', token: 'sk-...7890', created_at: 1700000000, last_used_at: null },
]
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
expect(screen.getByText('sk-...7890')).toBeInTheDocument()
})
})

View File

@ -12,12 +12,13 @@ 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 {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp'
@ -29,14 +30,19 @@ 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
canManage: boolean
onClose: () => void
}
@ -44,6 +50,7 @@ type ISecretKeyModalProps = {
const SecretKeyModal = ({
isShow = false,
appId,
datasetId,
canManage,
onClose,
}: ISecretKeyModalProps) => {
@ -53,12 +60,15 @@ const SecretKeyModal = ({
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [isVisible, setIsVisible] = 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('')
@ -69,10 +79,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)
@ -86,7 +100,9 @@ const SecretKeyModal = ({
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)
setIsVisible(true)
@ -97,8 +113,12 @@ const SecretKeyModal = ({
invalidateDatasetApiKeys()
}
const generateToken = (token: string) => {
return `${token.slice(0, 3)}...${token.slice(-20)}`
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 handleDeleteConfirmOpenChange = (open: boolean) => {
@ -129,7 +149,9 @@ const SecretKeyModal = ({
</DialogTitle>
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
<span className="i-heroicons-x-mark-20-solid size-6 cursor-pointer text-text-tertiary" onClick={handleClose} />
<button type="button" aria-label={t('operation.cancel', { ns: 'common' })} className="border-none bg-transparent p-0 text-text-tertiary" onClick={handleClose}>
<span className="i-heroicons-x-mark-20-solid size-6 cursor-pointer" />
</button>
</div>
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
{isApiKeysLoading && <div className="mt-4"><Loading /></div>}
@ -137,19 +159,20 @@ const SecretKeyModal = ({
!!apiKeysList?.data?.length && (
<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>
<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>
<div className="min-w-0 flex-[1.8] truncate px-3">{t('apiKeyModal.secretKey', { ns: 'appApi' })}</div>
{!appId && <div className="min-w-0 flex-[1.3] truncate px-3">{t('apiKeyModal.scope', { ns: 'appApi' })}</div>}
<div className="min-w-0 flex-[1.8] truncate px-3">{t('apiKeyModal.created', { ns: 'appApi' })}</div>
<div className="min-w-0 flex-[1.8] truncate px-3">{t('apiKeyModal.lastUsed', { ns: 'appApi' })}</div>
<div className="w-20 shrink-0 px-3"></div>
</div>
<div className="grow overflow-auto">
<div className="grow overflow-x-hidden overflow-y-auto">
{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>
<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">
<CopyFeedback content={api.token} />
<div className="min-w-0 flex-[1.8] truncate px-3 font-mono">{api.token}</div>
{!appId && <div className="min-w-0 flex-[1.3] truncate px-3" title={getScopeLabel(api.dataset_id)}>{getScopeLabel(api.dataset_id)}</div>}
<div className="min-w-0 flex-[1.8] truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
<div className="min-w-0 flex-[1.8] 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 w-20 shrink-0 justify-end space-x-2 px-3">
{canManage && (
<ActionButton
onClick={() => {
@ -167,6 +190,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 || !canManage}>
<span className="mr-1 i-heroicons-plus-20-solid 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",
"copied": "تم النسخ",

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",
"copied": "Kopiert",

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",
"copied": "Copied",

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",
"copied": "Copiado",

View File

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

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",
"copied": "Copié",

View File

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

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",
"copied": "Disalin",

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",
"copied": "Copiato",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "作成日時",
"apiKeyModal.generateTips": "このキーを安全でアクセス可能な場所に保管してください。",
"apiKeyModal.lastUsed": "最終使用日時",
"apiKeyModal.scope": "スコープ",
"apiKeyModal.scopeAllDatasets": "すべてのナレッジベース",
"apiKeyModal.scopeBoundDataset": "単一のナレッジベース",
"apiKeyModal.scopeThisDataset": "このナレッジベースのみ",
"apiKeyModal.secretKey": "シークレットキー",
"apiServer": "API サーバー",
"copied": "コピー済み",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "생성 날짜",
"apiKeyModal.generateTips": "이 키를 안전하고 접근 가능한 위치에 보관하십시오.",
"apiKeyModal.lastUsed": "최종 사용 날짜",
"apiKeyModal.scope": "범위",
"apiKeyModal.scopeAllDatasets": "모든 지식 베이스",
"apiKeyModal.scopeBoundDataset": "단일 지식 베이스",
"apiKeyModal.scopeThisDataset": "이 지식 베이스만",
"apiKeyModal.secretKey": "비밀 키",
"apiServer": "API 서버",
"copied": "복사 완료",

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",
"copied": "Copied",

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",
"copied": "Skopiowane",

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",
"copied": "Copiado",

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",
"copied": "Copiat",

View File

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

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",
"copied": "Kopirano",

View File

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

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",
"copied": "Kopyalandı",

View File

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

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",
"copied": "Đã sao chép",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "创建时间",
"apiKeyModal.generateTips": "请将此密钥保存在安全且可访问的地方。",
"apiKeyModal.lastUsed": "最后使用",
"apiKeyModal.scope": "范围",
"apiKeyModal.scopeAllDatasets": "所有知识库",
"apiKeyModal.scopeBoundDataset": "单个知识库",
"apiKeyModal.scopeThisDataset": "仅此知识库",
"apiKeyModal.secretKey": "密钥",
"apiServer": "API 服务器",
"copied": "已复制",

View File

@ -9,6 +9,10 @@
"apiKeyModal.created": "建立時間",
"apiKeyModal.generateTips": "請將此金鑰儲存在安全且可訪問的地方。",
"apiKeyModal.lastUsed": "最後使用",
"apiKeyModal.scope": "範圍",
"apiKeyModal.scopeAllDatasets": "所有知識庫",
"apiKeyModal.scopeBoundDataset": "單一知識庫",
"apiKeyModal.scopeThisDataset": "僅此知識庫",
"apiKeyModal.secretKey": "金鑰",
"apiServer": "API 伺服器",
"copied": "已複製",

View File

@ -79,6 +79,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
}
@ -90,6 +92,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 () => {