Compare commits

..

1 Commits

Author SHA1 Message Date
46a4d978f2 chore(deps): bump anthropics/claude-code-action
Bumps the github-actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.119 to 1.0.123
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](476e359e62...51ea8ea73a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.123
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 01:46:38 +00:00
243 changed files with 1892 additions and 11228 deletions

View File

@ -120,11 +120,7 @@ jobs:
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
cd api
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
uv run dev/generate_swagger_markdown_docs.py --swagger-dir openapi --markdown-dir openapi/markdown
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'

View File

@ -9,7 +9,6 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "feat/rbac"
tags:
- "*"

View File

@ -71,10 +71,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119
uses: anthropics/claude-code-action@51ea8ea73a139f2a74ff649e3092c25a904aed7e # v1.0.123
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -14,7 +14,6 @@ from .plugin import (
setup_system_trigger_oauth_client,
transform_datasource_credentials,
)
from .rbac import migrate_member_roles_to_rbac
from .retention import (
archive_workflow_runs,
clean_expired_messages,
@ -56,7 +55,6 @@ __all__ = [
"migrate_annotation_vector_database",
"migrate_data_for_plugin",
"migrate_knowledge_vector_database",
"migrate_member_roles_to_rbac",
"migrate_oss",
"old_metadata_migration",
"remove_orphaned_files_on_storage",

View File

@ -1,109 +0,0 @@
from __future__ import annotations
import click
from sqlalchemy import select
from core.db.session_factory import session_factory
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
The migration replays the old `TenantAccountJoin.role` values onto the
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
expected_builtin_name = {
TenantAccountRole.OWNER.value: "所有者",
TenantAccountRole.ADMIN.value: "管理者",
TenantAccountRole.EDITOR.value: "编辑者",
TenantAccountRole.NORMAL.value: "普通用户",
TenantAccountRole.DATASET_OPERATOR.value: "知识库操作员",
}.get(legacy_role)
if not expected_builtin_name:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
for role in roles:
if role.is_builtin and role.category == "global_system_default" and role.name == expected_builtin_name:
return str(role.id)
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
@click.command("rbac-migrate-member-roles", help="Migrate legacy workspace member roles into RBAC member-role bindings.")
@click.option("--tenant-id", help="Only migrate a single workspace.")
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
This is an offline migration command for workspaces that already have
members in the legacy role model but need matching records in the RBAC
member-role binding store.
"""
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
with session_factory.create_session() as session:
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
joins = list(session.scalars(stmt).all())
if not joins:
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
return
owner_account_by_tenant: dict[str, str] = {}
resolved_role_ids: dict[tuple[str, str], str] = {}
migrated_count = 0
for join in joins:
workspace_id = str(join.tenant_id)
member_account_id = str(join.account_id)
legacy_role = str(join.role)
if workspace_id not in owner_account_by_tenant:
owner_join = next(
(
item
for item in joins
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
),
None,
)
if not owner_join:
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
operator_account_id = owner_account_by_tenant[workspace_id]
cache_key = (workspace_id, legacy_role)
if cache_key not in resolved_role_ids:
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
resolved_role_id = resolved_role_ids[cache_key]
click.echo(
f"tenant={workspace_id} member={member_account_id} legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
if dry_run:
continue
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[resolved_role_id],
)
migrated_count += 1
if dry_run:
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
else:
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))

View File

@ -29,11 +29,6 @@ class EnterpriseFeatureConfig(BaseSettings):
"This helps gain runtime performance by trading off consistency.",
)
RBAC_ENABLED: bool = Field(
description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.",
default=False,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""

View File

@ -131,7 +131,6 @@ from .workspace import (
model_providers,
models,
plugin,
rbac,
tool_providers,
trigger_providers,
workspace,
@ -198,7 +197,6 @@ __all__ = [
"rag_pipeline_draft_variable",
"rag_pipeline_import",
"rag_pipeline_workflow",
"rbac",
"recommended_app",
"saved_message",
"setup",

View File

@ -30,7 +30,6 @@ from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.trigger.constants import TRIGGER_NODE_TYPES
from configs import dify_config
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
@ -41,7 +40,6 @@ from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
DataSource,
@ -347,7 +345,6 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
permission_keys: list[str] = Field(default_factory=list)
@computed_field(return_type=str | None) # type: ignore
@property
@ -383,7 +380,6 @@ class AppDetail(ResponseModel):
updated_at: int | None = None
access_mode: str | None = None
tags: list[Tag] = Field(default_factory=list)
permission_keys: list[str] = Field(default_factory=list)
@field_validator("created_at", "updated_at", mode="before")
@classmethod
@ -416,22 +412,6 @@ class AppExportResponse(ResponseModel):
data: str
def _collect_app_access_permission_keys(access_matrix: enterprise_rbac_service.AppAccessMatrix) -> list[str]:
permission_keys: list[str] = []
seen_permission_keys: set[str] = set()
for item in access_matrix.items:
if not item.policy:
continue
for permission_key in item.policy.permission_keys:
if permission_key in seen_permission_keys:
continue
seen_permission_keys.add(permission_key)
permission_keys.append(permission_key)
return permission_keys
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_schema_models(
@ -517,20 +497,6 @@ class AppListApi(Resource):
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
if app_pagination.items:
if dify_config.RBAC_ENABLED:
app_ids = [str(app.id) for app in app_pagination.items]
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
app_ids,
)
for app in app_pagination.items:
app.permission_keys = permission_keys_map.get(str(app.id), [])
else:
for app in app_pagination.items:
app.permission_keys = []
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
@ -608,7 +574,6 @@ class AppApi(Resource):
@get_app_model(mode=None)
def get(self, app_model):
"""Get app detail"""
current_user, current_tenant_id = current_account_with_tenant()
app_service = AppService()
app_model = app_service.get_app(app_model)
@ -617,16 +582,6 @@ class AppApi(Resource):
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
if dify_config.RBAC_ENABLED:
app_access_matrix = enterprise_rbac_service.RBACService.AppAccess.matrix(
str(current_tenant_id),
current_user.id,
str(app_model.id),
)
app_model.permission_keys = _collect_app_access_permission_keys(app_access_matrix)
else:
app_model.permission_keys = []
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")

View File

@ -57,7 +57,6 @@ from models.enums import ApiTokenType, SegmentStatus
from models.provider_ids import ModelProviderID
from services.api_token_service import ApiTokenCache
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.enterprise import rbac_service as enterprise_rbac_service
# Register models for flask_restx to avoid dict type issues in Swagger
dataset_base_model = get_or_create_model("DatasetBase", dataset_fields)
@ -128,14 +127,6 @@ def _validate_doc_form(value: str | None) -> str | None:
return value
def _ensure_permission_keys(dataset: Dataset, *, enabled: bool) -> None:
if not enabled:
setattr(dataset, "permission_keys", [])
return
if not isinstance(getattr(dataset, "permission_keys", None), list):
setattr(dataset, "permission_keys", [])
class DatasetCreatePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=40)
description: str = Field("", max_length=400)
@ -338,19 +329,6 @@ class DatasetListApi(Resource):
query.include_all,
)
for dataset in datasets:
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
if dify_config.RBAC_ENABLED and datasets:
dataset_ids = [str(dataset.id) for dataset in datasets]
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
str(current_tenant_id),
current_user.id,
dataset_ids,
)
for dataset in datasets:
setattr(dataset, "permission_keys", permission_keys_map.get(str(dataset.id), []))
# check embedding setting
provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id)
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
@ -432,7 +410,6 @@ class DatasetListApi(Resource):
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
return marshal(dataset, dataset_detail_fields), 201
@ -457,7 +434,6 @@ class DatasetApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
if dataset.embedding_model_provider:
@ -527,7 +503,6 @@ class DatasetApi(Resource):
if dataset is None:
raise NotFound("Dataset not found.")
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
result_data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
tenant_id = current_tenant_id

View File

@ -30,14 +30,13 @@ from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
class MemberInvitePayload(BaseModel):
emails: list[str] = Field(default_factory=list)
role: str
role: TenantAccountRole
language: str | None = None
@ -77,19 +76,6 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
def _serialize_member_roles(current_role: str | None, member_roles: list[enterprise_rbac_service.MemberRoleSummary]) -> list[dict[str, str]]:
if member_roles:
return [{"id": role.id, "name": role.name} for role in member_roles]
if current_role:
return [{"id": current_role, "name": current_role}]
return []
def _normalize_enum_value(value: object) -> str:
normalized = getattr(value, "value", value)
return str(normalized) if normalized is not None else ""
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@ -103,36 +89,7 @@ class MemberListApi(Resource):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_tenant_members(current_user.current_tenant)
if dify_config.RBAC_ENABLED:
member_ids = [member.id for member in members]
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
str(current_user.current_tenant.id),
current_user.id,
member_ids,
)
roles_map = {item.account_id: item.roles for item in member_roles}
else:
roles_map = {}
serialized_members = []
for member in members:
current_role = _normalize_enum_value(member.current_role)
serialized_members.append(
{
"id": member.id,
"name": member.name,
"email": member.email,
"avatar": member.avatar,
"last_login_at": member.last_login_at,
"last_active_at": member.last_active_at,
"created_at": member.created_at,
"role": current_role,
"roles": _serialize_member_roles(current_role, roles_map.get(member.id, [])),
"status": _normalize_enum_value(member.status),
}
)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
@ -153,9 +110,8 @@ class MemberInviteEmailApi(Resource):
invitee_emails = args.emails
invitee_role = args.role
interface_language = args.language
if not dify_config.RBAC_ENABLED:
if not TenantAccountRole.is_valid_role(invitee_role) or not TenantAccountRole.is_non_owner_role(invitee_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
if not TenantAccountRole.is_non_owner_role(invitee_role):
return {"code": "invalid-role", "message": "Invalid role"}, 400
current_user, _ = current_account_with_tenant()
inviter = current_user
if not inviter.current_tenant:

View File

@ -1,614 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.console import console_ns
from libs.login import current_account_with_tenant, login_required
from services.enterprise import rbac_service as svc
_LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = {
# This is a compatibility projection from the pre-RBAC workspace roles into
# the 2.0 permission matrix documented in "权限整理2.0". It intentionally
# models the product-facing role surface for the new RBAC UI instead of the
# legacy backend's exact hard-authorization checks.
"owner": [
*svc._LEGACY_WORKSPACE_OWNER_KEYS,
*svc._LEGACY_APP_OWNER_KEYS,
*svc._LEGACY_DATASET_OWNER_KEYS,
],
"admin": [
*svc._LEGACY_WORKSPACE_ADMIN_KEYS,
*svc._LEGACY_APP_ADMIN_KEYS,
*svc._LEGACY_DATASET_ADMIN_KEYS,
],
"editor": [
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
*svc._LEGACY_APP_EDITOR_KEYS,
*svc._LEGACY_DATASET_EDITOR_KEYS,
],
"normal": [
*svc._LEGACY_WORKSPACE_NORMAL_KEYS,
*svc._LEGACY_APP_NORMAL_KEYS,
],
"dataset_operator": [
*svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
*svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
],
}
def _current_ids() -> tuple[str, str]:
"""Return ``(tenant_id, account_id)`` for the authenticated user, or
raise a 404 when no tenant is associated with the session.
"""
user, tenant_id = current_account_with_tenant()
if not tenant_id:
raise NotFound("Current workspace not found")
return tenant_id, user.id
def _payload(model: type[BaseModel]) -> Any:
"""Validate the JSON body against ``model`` or raise ``ValidationError``.
``ValidationError`` bubbles up as HTTP 400 thanks to
``controllers/common/helpers.py`` error handling.
"""
try:
return model.model_validate(console_ns.payload or {})
except ValidationError as exc:
# Re-raise as-is so the upstream error handler renders a 400.
raise exc
def _dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump(mode="json")
class _PaginationQuery(BaseModel):
model_config = ConfigDict(extra="ignore")
page_number: int | None = Field(default=None, ge=1, validation_alias=AliasChoices("page", "page_number"))
results_per_page: int | None = Field(
default=None, ge=1, le=100, validation_alias=AliasChoices("limit", "results_per_page")
)
reverse: bool | None = None
def to_inner_options(self) -> svc.ListOption:
return svc.ListOption.model_validate(self.model_dump())
class _RolesListQuery(_PaginationQuery):
include_owner: int = Field(default=0, ge=0, le=1)
def _pagination_options() -> svc.ListOption:
return _PaginationQuery.model_validate(request.args.to_dict(flat=True)).to_inner_options()
def _filter_out_owner(paginated: svc.Paginated[svc.RBACRole]) -> svc.Paginated[svc.RBACRole]:
filtered = [r for r in paginated.data if r.name not in {"所有者", "owner"}]
return svc.Paginated[svc.RBACRole](
data=filtered,
pagination=paginated.pagination,
)
def _legacy_workspace_roles(options: svc.ListOption | None = None) -> svc.Paginated[svc.RBACRole]:
"""Return the built-in legacy workspace roles in the RBAC list shape.
This keeps the new `/rbac/roles` endpoint compatible with the original
Dify role model when enterprise RBAC is disabled.
"""
legacy_roles = [
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]),
role_tag="owner" if role_name == "owner" else "",
)
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
]
page_number = options.page_number if options and options.page_number is not None else 1
results_per_page = options.results_per_page if options and options.results_per_page is not None else len(legacy_roles)
reverse = options.reverse if options and options.reverse is not None else False
ordered_roles = list(reversed(legacy_roles)) if reverse else legacy_roles
start = max(page_number - 1, 0) * results_per_page
end = start + results_per_page
paged_roles = ordered_roles[start:end]
total_count = len(legacy_roles)
total_pages = (total_count + results_per_page - 1) // results_per_page if results_per_page > 0 else 0
return svc.Paginated[svc.RBACRole](
data=paged_roles,
pagination=svc.Pagination(
total_count=total_count,
per_page=results_per_page,
current_page=page_number,
total_pages=total_pages,
),
)
# ---------------------------------------------------------------------------
# Permission catalogs.
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog")
class RBACWorkspaceCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.workspace(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/app")
class RBACAppCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.app(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/dataset")
class RBACDatasetCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.dataset(tenant_id, account_id))
# ---------------------------------------------------------------------------
# Roles.
# ---------------------------------------------------------------------------
class _RoleUpsertRequest(BaseModel):
"""Accepts the payload sent by the Create/Edit Role dialog."""
name: str
description: str = ""
permission_keys: list[str] = []
def to_mutation(self) -> svc.RoleMutation:
return svc.RoleMutation(
name=self.name,
description=self.description,
permission_keys=list(self.permission_keys),
)
@console_ns.route("/workspaces/current/rbac/roles")
class RBACRolesApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
query = _RolesListQuery.model_validate(request.args.to_dict(flat=True))
options = query.to_inner_options()
if not dify_config.RBAC_ENABLED:
result = _legacy_workspace_roles(options)
else:
result = svc.RBACService.Roles.list(tenant_id, account_id, options=options)
if query.include_owner == 0:
result = _filter_out_owner(result)
data = []
for role in result.data:
if role.name in {"所有者", "owner"}:
role.role_tag = "owner"
else:
role.role_tag = ""
data.append(role)
result.data = data
return _dump(result)
@login_required
def post(self):
tenant_id, account_id = _current_ids()
request = _payload(_RoleUpsertRequest)
role = svc.RBACService.Roles.create(tenant_id, account_id, request.to_mutation())
return _dump(role), 201
@console_ns.route("/workspaces/current/rbac/roles/<uuid:role_id>")
class RBACRoleItemApi(Resource):
@login_required
def get(self, role_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Roles.get(tenant_id, account_id, str(role_id)))
@login_required
def put(self, role_id):
tenant_id, account_id = _current_ids()
request = _payload(_RoleUpsertRequest)
role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation())
return _dump(role)
@login_required
def delete(self, role_id):
tenant_id, account_id = _current_ids()
svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id))
return {"result": "success"}
@console_ns.route("/workspaces/current/rbac/roles/<uuid:role_id>/copy")
class RBACRoleCopyApi(Resource):
@login_required
def post(self, role_id):
tenant_id, account_id = _current_ids()
role = svc.RBACService.Roles.copy(tenant_id, account_id, str(role_id))
return _dump(role), 201
# ---------------------------------------------------------------------------
# Access policies (tenant-level permission sets).
# ---------------------------------------------------------------------------
class _AccessPolicyCreateRequest(BaseModel):
name: str
resource_type: svc.RBACResourceType
description: str = ""
permission_keys: list[str] = []
class _AccessPolicyUpdateRequest(BaseModel):
name: str
description: str = ""
permission_keys: list[str] = []
@console_ns.route("/workspaces/current/rbac/access-policies")
class RBACAccessPoliciesApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
# `resource_type` is exposed as a query argument so the UI can show
# only app-scoped or only dataset-scoped permission sets.
resource_type = request.args.get("resource_type") or None
return _dump(
svc.RBACService.AccessPolicies.list(
tenant_id,
account_id,
resource_type=resource_type,
options=_pagination_options(),
)
)
@login_required
def post(self):
tenant_id, account_id = _current_ids()
request = _payload(_AccessPolicyCreateRequest)
policy = svc.RBACService.AccessPolicies.create(
tenant_id,
account_id,
svc.AccessPolicyCreate(
name=request.name,
resource_type=request.resource_type,
description=request.description,
permission_keys=list(request.permission_keys),
),
)
return _dump(policy), 201
@console_ns.route("/workspaces/current/rbac/access-policies/<uuid:policy_id>")
class RBACAccessPolicyItemApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AccessPolicies.get(tenant_id, account_id, str(policy_id)))
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_AccessPolicyUpdateRequest)
policy = svc.RBACService.AccessPolicies.update(
tenant_id,
account_id,
str(policy_id),
svc.AccessPolicyUpdate(
name=request.name,
description=request.description,
permission_keys=list(request.permission_keys),
),
)
return _dump(policy)
@login_required
def delete(self, policy_id):
tenant_id, account_id = _current_ids()
svc.RBACService.AccessPolicies.delete(tenant_id, account_id, str(policy_id))
return {"result": "success"}
@console_ns.route("/workspaces/current/rbac/access-policies/<uuid:policy_id>/copy")
class RBACAccessPolicyCopyApi(Resource):
@login_required
def post(self, policy_id):
tenant_id, account_id = _current_ids()
policy = svc.RBACService.AccessPolicies.copy(tenant_id, account_id, str(policy_id))
return _dump(policy), 201
# ---------------------------------------------------------------------------
# Per-app access (App Access Config).
# ---------------------------------------------------------------------------
class _ReplaceBindingsRequest(BaseModel):
role_ids: list[str] = []
account_ids: list[str] = []
@field_validator("role_ids", "account_ids", mode="before")
@classmethod
def _coerce_bindings(cls, value: Any) -> list[str]:
if value is None:
return []
return value
@console_ns.route("/workspaces/current/rbac/my-permissions")
class RBACMyPermissionsApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.MyPermissions.get(
tenant_id,
account_id,
app_id=request.args.get("app_id") or None,
dataset_id=request.args.get("dataset_id") or None,
)
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policy")
class RBACAppMatrixApi(Resource):
@login_required
def get(self, app_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AppAccess.matrix(tenant_id, account_id, str(app_id)))
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/role-bindings")
class RBACAppRoleBindingsApi(Resource):
@login_required
def get(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.AppAccess.list_role_bindings(tenant_id, account_id, str(app_id), str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/member-bindings")
class RBACAppMemberBindingsApi(Resource):
@login_required
def get(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.AppAccess.list_member_bindings(tenant_id, account_id, str(app_id), str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/bindings")
class RBACAppBindingsApi(Resource):
@login_required
def put(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.AppAccess.replace_bindings(
tenant_id,
account_id,
str(app_id),
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
# ---------------------------------------------------------------------------
# Per-dataset access (Knowledge Base Access Config).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policy")
class RBACDatasetMatrixApi(Resource):
@login_required
def get(self, dataset_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.DatasetAccess.matrix(tenant_id, account_id, str(dataset_id)))
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/role-bindings")
class RBACDatasetRoleBindingsApi(Resource):
@login_required
def get(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.DatasetAccess.list_role_bindings(
tenant_id, account_id, str(dataset_id), str(policy_id)
)
)
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/bindings")
class RBACDatasetBindingsApi(Resource):
@login_required
def put(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.DatasetAccess.replace_bindings(
tenant_id,
account_id,
str(dataset_id),
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route(
"/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/member-bindings"
)
class RBACDatasetMemberBindingsApi(Resource):
@login_required
def get(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.DatasetAccess.list_member_bindings(
tenant_id, account_id, str(dataset_id), str(policy_id)
)
)
# ---------------------------------------------------------------------------
# Workspace-level access (Settings > Access Rules).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy")
class RBACWorkspaceAppMatrixApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
options = _pagination_options()
return _dump(svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id, options=options))
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceAppRoleBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_app_role_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/bindings")
class RBACWorkspaceAppBindingsApi(Resource):
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_app_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceAppMemberBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_app_member_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy")
class RBACWorkspaceDatasetMatrixApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
options = _pagination_options()
return _dump(svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id, options=options))
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceDatasetRoleBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_dataset_role_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/bindings")
class RBACWorkspaceDatasetBindingsApi(Resource):
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_dataset_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceDatasetMemberBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_dataset_member_bindings(tenant_id, account_id, str(policy_id))
)
# ---------------------------------------------------------------------------
# Member ↔ role bindings (Settings > Members > Assign roles).
# ---------------------------------------------------------------------------
class _ReplaceMemberRolesRequest(BaseModel):
role_ids: list[str] = []
@field_validator("role_ids", mode="before")
@classmethod
def _coerce_role_ids(cls, value: Any) -> list[str]:
if value is None:
return []
return value
@console_ns.route("/workspaces/current/rbac/members/<uuid:member_id>/rbac-roles")
class RBACMemberRolesApi(Resource):
@login_required
def get(self, member_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.MemberRoles.get(tenant_id, account_id, str(member_id)))
@login_required
def put(self, member_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberRolesRequest)
return _dump(
svc.RBACService.MemberRoles.replace(
tenant_id,
account_id,
str(member_id),
role_ids=list(request.role_ids),
)
)

View File

@ -21,7 +21,6 @@ def init_app(app: DifyApp):
install_plugins,
install_rag_pipeline_plugins,
migrate_data_for_plugin,
migrate_member_roles_to_rbac,
migrate_oss,
old_metadata_migration,
remove_orphaned_files_on_storage,
@ -48,7 +47,6 @@ def init_app(app: DifyApp):
upgrade_db,
fix_app_site_missing,
migrate_data_for_plugin,
migrate_member_roles_to_rbac,
extract_plugins,
extract_unique_plugins,
install_plugins,

View File

@ -80,7 +80,6 @@ app_detail_fields = {
"updated_at": TimestampField,
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String),
}
prompt_config_fields = {
@ -118,7 +117,6 @@ app_partial_fields = {
"create_user_name": fields.String,
"author_name": fields.String,
"has_draft_trigger": fields.Boolean,
"permission_keys": fields.List(fields.String),
}
@ -199,7 +197,6 @@ app_detail_fields_with_site = {
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_fields)),
"permission_keys": fields.List(fields.String),
"site": fields.Nested(site_fields),
}

View File

@ -11,7 +11,6 @@ dataset_fields = {
"indexing_technique": fields.String,
"created_by": fields.String,
"created_at": TimestampField,
"permission_keys": fields.List(fields.String),
}
reranking_model_fields = {"reranking_provider_name": fields.String, "reranking_model_name": fields.String}
@ -108,7 +107,6 @@ dataset_detail_fields = {
"total_available_documents": fields.Integer,
"enable_api": fields.Boolean,
"is_multimodal": fields.Boolean,
"permission_keys": fields.List(fields.String),
}
file_info_fields = {

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from flask_restx import fields
from pydantic import Field, computed_field, field_validator
from pydantic import computed_field, field_validator
from fields.base import ResponseModel
from libs.helper import build_avatar_url, to_timestamp
@ -56,7 +56,6 @@ class AccountWithRole(_AccountAvatar):
last_active_at: int | None = None
created_at: int | None = None
role: str
roles: list[dict[str, str]] = Field(default_factory=list)
status: str
@field_validator("last_login_at", "last_active_at", "created_at", mode="before")

View File

@ -11,8 +11,6 @@ from sqlalchemy import DateTime, String, func, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from typing_extensions import deprecated
from configs import dify_config
from .base import TypeBase
from .engine import db
from .types import EnumText, LongText, StringUUID
@ -189,14 +187,10 @@ class Account(UserMixin, TypeBase):
# check current_user.current_tenant.current_role in ['admin', 'owner']
@property
def is_admin_or_owner(self):
if dify_config.RBAC_ENABLED:
return True
return TenantAccountRole.is_privileged_role(self.role)
@property
def is_admin(self):
if dify_config.RBAC_ENABLED:
return True
return TenantAccountRole.is_admin_role(self.role)
@property
@ -222,20 +216,14 @@ class Account(UserMixin, TypeBase):
- `ADMIN`
- `EDITOR`
"""
if dify_config.RBAC_ENABLED:
return True
return TenantAccountRole.is_editing_role(self.role)
@property
def is_dataset_editor(self):
if dify_config.RBAC_ENABLED:
return True
return TenantAccountRole.is_dataset_edit_role(self.role)
@property
def is_dataset_operator(self):
if dify_config.RBAC_ENABLED:
return True
return self.role == TenantAccountRole.DATASET_OPERATOR

View File

@ -62,7 +62,6 @@ from services.errors.account import (
TenantNotFoundError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.enterprise.rbac_service import ListOption, RBACService
from services.feature_service import FeatureService
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
@ -139,38 +138,6 @@ class AccountService:
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
EMAIL_REGISTER_MAX_ERROR_LIMITS = 5
@staticmethod
def resolve_workspace_rbac_role_id(tenant_id: str, account_id: str, role_identifier: str) -> str:
"""Resolve a legacy workspace role name or RBAC role id to a concrete RBAC role id.
The members API historically speaks in legacy role names (`owner`, `admin`,
`editor`, ...). When RBAC is enabled we must replace member-role bindings
with the actual RBAC role id returned by the RBAC service.
"""
options = ListOption(page_number=1, results_per_page=100)
roles = RBACService.Roles.list(tenant_id, account_id, options=options).data
normalized_identifier = role_identifier.strip().lower()
expected_builtin_name = {
TenantAccountRole.OWNER.value: "所有者",
TenantAccountRole.ADMIN.value: "管理者",
TenantAccountRole.EDITOR.value: "编辑者",
TenantAccountRole.NORMAL.value: "普通用户",
TenantAccountRole.DATASET_OPERATOR.value: "知识库操作员",
}.get(normalized_identifier)
for role in roles:
role_id = str(role.id)
role_name = str(role.name)
if role_id == role_identifier:
return role_id
if expected_builtin_name and role.is_builtin and role.category == "global_system_default":
if role_name == expected_builtin_name:
return role_id
if role_name.strip().lower() == normalized_identifier:
return role_id
raise ValueError(f"Workspace RBAC role not found for identifier: {role_identifier}")
@staticmethod
def _get_refresh_token_key(refresh_token: str) -> str:
return f"{REFRESH_TOKEN_PREFIX}{refresh_token}"
@ -1157,18 +1124,6 @@ class TenantService:
else:
tenant = TenantService.create_tenant(name=f"{account.name}'s Workspace", is_setup=is_setup)
TenantService.create_tenant_member(tenant, account, role="owner")
if dify_config.RBAC_ENABLED:
resolved_role_id = AccountService.resolve_workspace_rbac_role_id(
tenant_id=str(tenant.id),
account_id=account.id,
role_identifier="所有者",
)
RBACService.MemberRoles.replace(
tenant_id=str(tenant.id),
account_id=account.id,
member_account_id=account.id,
role_ids=[resolved_role_id],
)
account.current_tenant = tenant
db.session.commit()
tenant_was_created.send(tenant)
@ -1460,37 +1415,11 @@ class TenantService:
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner")
.limit(1)
)
if not dify_config.RBAC_ENABLED:
if current_owner_join:
current_owner_join.role = TenantAccountRole.ADMIN
else:
admin_role_id = AccountService.resolve_workspace_rbac_role_id(
tenant_id=str(tenant.id),
account_id=operator.id,
role_identifier=TenantAccountRole.ADMIN.value,
)
RBACService.MemberRoles.replace(
tenant_id=str(tenant.id),
account_id=operator.id,
member_account_id=str(current_owner_join.account_id),
role_ids=[admin_role_id],
)
if current_owner_join:
current_owner_join.role = TenantAccountRole.ADMIN
# Update the role of the target member
if dify_config.RBAC_ENABLED:
resolved_role_id = AccountService.resolve_workspace_rbac_role_id(
tenant_id=str(tenant.id),
account_id=operator.id,
role_identifier=TenantAccountRole.OWNER.value,
)
RBACService.MemberRoles.replace(
tenant_id=str(tenant.id),
account_id=operator.id,
member_account_id=member.id,
role_ids=[resolved_role_id],
)
else:
target_member_join.role = new_tenant_role
target_member_join.role = new_tenant_role
db.session.commit()
@staticmethod
@ -1644,9 +1573,8 @@ class RegisterService:
status=AccountStatus.PENDING,
is_setup=True,
)
# Create new tenant member for invited tenant (legacy path)
if not dify_config.RBAC_ENABLED:
TenantService.create_tenant_member(tenant, account, role)
# Create new tenant member for invited tenant
TenantService.create_tenant_member(tenant, account, role)
TenantService.switch_tenant(account, tenant.id)
else:
TenantService.check_member_permission(tenant, inviter, account, "add")
@ -1657,27 +1585,12 @@ class RegisterService:
)
if not ta:
if not dify_config.RBAC_ENABLED:
TenantService.create_tenant_member(tenant, account, role)
TenantService.create_tenant_member(tenant, account, role)
# Support resend invitation email when the account is pending status
if account.status != AccountStatus.PENDING:
raise AccountAlreadyInTenantError("Account already in tenant.")
# Assign RBAC role if RBAC is enabled
if dify_config.RBAC_ENABLED:
resolved_role_id = AccountService.resolve_workspace_rbac_role_id(
tenant_id=str(tenant.id),
account_id=inviter.id,
role_identifier=role,
)
RBACService.MemberRoles.replace(
tenant_id=str(tenant.id),
account_id=inviter.id,
member_account_id=account.id,
role_ids=[resolved_role_id],
)
token = cls.generate_invite_token(tenant, account)
language = account.interface_language or "en-US"

View File

@ -5,7 +5,6 @@ from typing import Any
import httpx
from configs import dify_config
from core.helper.trace_id_helper import generate_traceparent_header
from services.errors.enterprise import (
EnterpriseAPIBadRequestError,
@ -17,11 +16,6 @@ from services.errors.enterprise import (
logger = logging.getLogger(__name__)
# Headers recognised by dify-enterprise's /inner/api/rbac/* endpoints.
# Keep in sync with pkg/enterprise/service/rbac_inner_handlers.go.
INNER_TENANT_ID_HEADER = "X-Inner-Tenant-Id"
INNER_ACCOUNT_ID_HEADER = "X-Inner-Account-Id"
class BaseRequest:
proxies: Mapping[str, str] | None = {
@ -55,16 +49,8 @@ class BaseRequest:
*,
timeout: float | httpx.Timeout | None = None,
raise_for_status: bool = False,
extra_headers: Mapping[str, str] | None = None,
) -> Any:
headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key}
if extra_headers:
# Explicitly ignore empty values so callers can pass optional
# headers (e.g. `X-Inner-Account-Id`) without having to branch.
for key, value in extra_headers.items():
if value is None or value == "":
continue
headers[key] = value
url = f"{cls.base_url}{endpoint}"
mounts = cls._build_mounts()
@ -133,56 +119,9 @@ class BaseRequest:
class EnterpriseRequest(BaseRequest):
base_url = os.environ.get("ENTERPRISE_API_URL", "ENTERPRISE_API_URL")
rbac_base_url = os.environ.get("ENTERPRISE_RBAC_API_URL", base_url)
secret_key = os.environ.get("ENTERPRISE_API_SECRET_KEY", "ENTERPRISE_API_SECRET_KEY")
secret_key_header = "Enterprise-Api-Secret-Key"
@classmethod
def send_inner_rbac_request(
cls,
method: str,
endpoint: str,
*,
tenant_id: str,
account_id: str | None = None,
json: Any | None = None,
params: Mapping[str, Any] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Call an /inner/api/rbac/* endpoint on dify-enterprise.
Inner RBAC endpoints require three headers on top of the standard
Enterprise-Api-Secret-Key: the tenant the call targets and (optionally)
the account acting on behalf of the workspace. This helper centralises
both the assertions and the header wiring so callers only have to
supply business payload.
"""
if not tenant_id:
raise ValueError("tenant_id must be provided for inner RBAC requests")
inner_headers: dict[str, str] = {INNER_TENANT_ID_HEADER: tenant_id}
if account_id:
inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id
url = f"{cls.rbac_base_url}{endpoint}"
mounts = cls._build_mounts()
try:
traceparent = generate_traceparent_header()
if traceparent:
inner_headers = dict(inner_headers)
inner_headers["traceparent"] = traceparent
except Exception:
logger.debug("Failed to generate traceparent header", exc_info=True)
with httpx.Client(mounts=mounts) as client:
request_kwargs: dict[str, Any] = {"json": json, "params": params, "headers": {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key, **inner_headers}}
if timeout is not None:
request_kwargs["timeout"] = timeout
response = client.request(method, url, **request_kwargs)
if not response.is_success:
cls._handle_error_response(response)
return response.json()
class EnterprisePluginManagerRequest(BaseRequest):
base_url = os.environ.get("ENTERPRISE_PLUGIN_MANAGER_API_URL", "ENTERPRISE_PLUGIN_MANAGER_API_URL")

File diff suppressed because it is too large Load Diff

View File

@ -180,7 +180,6 @@ class SystemFeatureModel(BaseModel):
enable_creators_platform: bool = False
enable_trial_app: bool = False
enable_explore_banner: bool = False
rbac_enabled: bool = False
class FeatureService:
@ -230,7 +229,6 @@ class FeatureService:
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel()
system_features.app_dsl_version = CURRENT_APP_DSL_VERSION
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)

View File

@ -13,8 +13,6 @@ from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.datastructures import MultiDict
from configs import dify_config
# kombu references MethodView as a global when importing celery/kombu pools.
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@ -295,7 +293,6 @@ def test_app_partial_serialization_uses_aliases(app_models):
create_user_name="Creator",
author_name="Author",
has_draft_trigger=True,
permission_keys=["app.acl.view_layout"],
)
serialized = AppPartial.model_validate(app_obj, from_attributes=True).model_dump(mode="json")
@ -308,7 +305,6 @@ def test_app_partial_serialization_uses_aliases(app_models):
assert serialized["model_config"]["model"] == {"provider": "openai", "name": "gpt-4o"}
assert serialized["workflow"]["id"] == "wf-1"
assert serialized["tags"][0]["name"] == "Utilities"
assert serialized["permission_keys"] == ["app.acl.view_layout"]
def test_app_detail_with_site_includes_nested_serialization(app_models):
@ -346,7 +342,6 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
updated_at=timestamp,
access_mode="public",
tags=[SimpleNamespace(id="tag-2", name="Prod", type="app")],
permission_keys=["app.acl.view_layout", "app.acl.edit"],
api_base_url="https://api.example.com/v1",
max_active_requests=5,
deleted_tools=[{"type": "api", "tool_name": "search", "provider_id": "prov"}],
@ -360,7 +355,6 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
assert serialized["deleted_tools"][0]["tool_name"] == "search"
assert serialized["site"]["icon_url"] == "signed:site-icon"
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_app_pagination_aliases_per_page_and_has_next(app_models):
@ -374,7 +368,6 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
icon="first-icon",
created_at=_ts(15),
updated_at=_ts(15),
permission_keys=["app.acl.edit"],
)
item_two = SimpleNamespace(
id="app-11",
@ -402,102 +395,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
assert len(serialized["data"]) == 2
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
assert serialized["data"][1]["icon_url"] is None
assert serialized["data"][0]["permission_keys"] == ["app.acl.edit"]
def test_app_list_api_attaches_permission_keys(app, app_module):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
app_obj = SimpleNamespace(
id="app-1",
name="List App",
desc_or_prompt="Summary",
mode_compatible_with_agent="chat",
mode="chat",
created_at=_ts(15),
updated_at=_ts(15),
permission_keys=[],
)
pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_obj])
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="acct-1"), "tenant-1"),
)
monkeypatch.setattr(
app_module.AppService,
"get_paginate_apps",
lambda self, user_id, tenant_id, args_dict: pagination,
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppPermissions,
"batch_get",
lambda tenant_id, account_id, app_ids: {"app-1": ["app.acl.view_layout", "app.acl.edit"]},
)
resp, status = method(app_module.AppListApi())
assert status == 200
assert app_obj.permission_keys == ["app.acl.view_layout", "app.acl.edit"]
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_app_detail_api_attaches_permission_keys_from_access_matrix(app, app_module):
method = app_module.AppApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
app_obj = SimpleNamespace(
id="app-1",
name="Detail App",
description="Summary",
mode_compatible_with_agent="chat",
enable_site=True,
enable_api=True,
permission_keys=[],
)
with app.test_request_context("/apps/app-1"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="acct-1"), "tenant-1"),
)
monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=lambda app_model: app_obj))
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppAccess,
"matrix",
lambda tenant_id, account_id, app_id: SimpleNamespace(
items=[
SimpleNamespace(
policy=SimpleNamespace(permission_keys=["app.acl.view_layout", "app.acl.edit"])
),
SimpleNamespace(
policy=SimpleNamespace(permission_keys=["app.acl.edit", "app.log.access"])
),
]
),
)
resp = method(app_module.AppApi(), app_model=app_obj)
assert app_obj.permission_keys == ["app.acl.view_layout", "app.acl.edit", "app.log.access"]
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.log.access"]

View File

@ -52,9 +52,7 @@ class TestMemberInviteEmailApi:
inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config") as mock_config:
mock_config.RBAC_ENABLED = False
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
with patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"):
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
@ -77,116 +75,3 @@ class TestMemberInviteEmailApi:
assert call_args.kwargs["role"] == TenantAccountRole.EDITOR
assert call_args.kwargs["inviter"] == inviter
mock_csrf.assert_called_once()
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.RegisterService.invite_new_member")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@patch("controllers.console.wraps.db")
@patch("libs.login.check_csrf_token", return_value=None)
def test_invite_rbac_enabled_accepts_rbac_role_id(
self,
mock_csrf,
mock_db,
mock_current_account,
mock_invite_member,
mock_get_features,
app,
):
"""When RBAC is enabled, any non-empty role string should be accepted."""
mock_get_features.return_value = _build_feature_flags()
mock_invite_member.return_value = "rbac-token"
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config") as mock_config:
mock_config.RBAC_ENABLED = True
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
json={"emails": ["user@example.com"], "role": "rbac-role-id-abc", "language": "en-US"},
):
account = Account(name="tester", email="tester@example.com")
account._current_tenant = tenant
g._login_user = account
g._current_tenant = tenant
response, status_code = MemberInviteEmailApi().post()
assert status_code == 201
mock_invite_member.assert_called_once()
call_args = mock_invite_member.call_args
assert call_args.kwargs["role"] == "rbac-role-id-abc"
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@patch("controllers.console.wraps.db")
@patch("libs.login.check_csrf_token", return_value=None)
def test_invite_rbac_disabled_rejects_invalid_role(
self,
mock_csrf,
mock_db,
mock_current_account,
mock_get_features,
app,
):
"""When RBAC is disabled, an invalid role string should be rejected."""
mock_get_features.return_value = _build_feature_flags()
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config") as mock_config:
mock_config.RBAC_ENABLED = False
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
json={"emails": ["user@example.com"], "role": "invalid-role", "language": "en-US"},
):
account = Account(name="tester", email="tester@example.com")
account._current_tenant = tenant
g._login_user = account
g._current_tenant = tenant
response, status_code = MemberInviteEmailApi().post()
assert status_code == 400
assert response["code"] == "invalid-role"
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@patch("controllers.console.wraps.db")
@patch("libs.login.check_csrf_token", return_value=None)
def test_invite_rbac_disabled_rejects_owner_role(
self,
mock_csrf,
mock_db,
mock_current_account,
mock_get_features,
app,
):
"""When RBAC is disabled, owner role should be rejected for invite."""
mock_get_features.return_value = _build_feature_flags()
tenant = SimpleNamespace(id="tenant-1", name="Test Tenant")
inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config") as mock_config:
mock_config.RBAC_ENABLED = False
mock_config.CONSOLE_WEB_URL = "https://console.example.com"
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
json={"emails": ["user@example.com"], "role": "owner", "language": "en-US"},
):
account = Account(name="tester", email="tester@example.com")
account._current_tenant = tenant
g._login_user = account
g._current_tenant = tenant
response, status_code = MemberInviteEmailApi().post()
assert status_code == 400
assert response["code"] == "invalid-role"

View File

@ -1,4 +1,3 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@ -47,8 +46,8 @@ class TestMemberListApi:
member.name = "Member"
member.email = "member@test.com"
member.avatar = "avatar.png"
member.current_role = SimpleNamespace(value="admin")
member.status = SimpleNamespace(value="active")
member.role = "admin"
member.status = "active"
members = [member]
with (
@ -60,53 +59,6 @@ class TestMemberListApi:
assert status == 200
assert len(result["accounts"]) == 1
assert result["accounts"][0]["role"] == "admin"
assert result["accounts"][0]["roles"] == [{"id": "admin", "name": "admin"}]
def test_get_with_rbac_enabled_fetches_roles_in_batch(self, app):
api = MemberListApi()
method = unwrap(api.get)
tenant = MagicMock(id="tenant-1")
user = MagicMock(id="acct-1", current_tenant=tenant)
member = SimpleNamespace(
id="m1",
name="Member",
email="member@test.com",
avatar=None,
last_login_at=1,
last_active_at=2,
created_at=3,
current_role=SimpleNamespace(value="editor"),
status=SimpleNamespace(value="active"),
)
role_item = SimpleNamespace(
account_id="m1",
roles=[
SimpleNamespace(id="workspace.owner", name="Owner"),
SimpleNamespace(id="workspace.editor", name="Editor"),
],
)
with (
app.test_request_context("/"),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "tenant-1")),
patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", True),
patch("controllers.console.workspace.members.TenantService.get_tenant_members", return_value=[member]),
patch(
"controllers.console.workspace.members.enterprise_rbac_service.RBACService.MemberRoles.batch_get",
return_value=[role_item],
) as mock_batch_get,
):
result, status = method(api)
assert status == 200
assert result["accounts"][0]["role"] == "editor"
assert result["accounts"][0]["roles"] == [
{"id": "workspace.owner", "name": "Owner"},
{"id": "workspace.editor", "name": "Editor"},
]
mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["m1"])
def test_get_no_tenant(self, app: Flask):
api = MemberListApi()

View File

@ -1,288 +0,0 @@
"""Controller tests for ``controllers.console.workspace.rbac``.
The controllers here are thin: almost every non-trivial behaviour lives in
``services.enterprise.rbac_service`` (covered by its own suite). These tests
therefore focus on the Flask-layer concerns the service layer cannot exercise:
* ``_current_ids`` raises 404 when the session has no tenant.
* The pydantic request models accept / reject bodies as expected.
We explicitly avoid "happy-path" integration tests through the full
decorator stack — those belong in e2e tests where a real Dify session is
available — to keep this suite fast and resilient to ancillary auth wiring
changes.
"""
from __future__ import annotations
from types import SimpleNamespace
import inspect
from unittest.mock import patch
import pytest
from flask import Flask
from pydantic import ValidationError
from werkzeug.exceptions import NotFound
from controllers.console.workspace import rbac as rbac_mod
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
def _enabled(enabled: bool):
return patch("controllers.console.workspace.rbac.dify_config.ENTERPRISE_ENABLED", enabled)
class TestCurrentIds:
def test_rejects_missing_tenant(self):
with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user:
mock_user.return_value = (SimpleNamespace(id="acct-1"), None)
with pytest.raises(NotFound):
rbac_mod._current_ids()
def test_returns_tuple(self):
with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user:
mock_user.return_value = (SimpleNamespace(id="acct-1"), "tenant-1")
assert rbac_mod._current_ids() == ("tenant-1", "acct-1")
class TestPydanticModels:
"""The internal `_…Request` models are the contract between the browser
and the controllers. We only check non-obvious branches (enum parsing,
missing required fields) — trivial `str` fields are not worth asserting.
"""
def test_role_upsert_requires_name(self):
with pytest.raises(ValidationError):
rbac_mod._RoleUpsertRequest.model_validate({})
def test_role_upsert_to_mutation_preserves_fields(self):
payload = rbac_mod._RoleUpsertRequest.model_validate(
{
"name": "Owner",
"description": "full access",
"permission_keys": ["workspace.member.manage"],
}
)
mutation = payload.to_mutation()
assert mutation.description == "full access"
assert mutation.permission_keys == ["workspace.member.manage"]
def test_access_policy_create_parses_resource_type_enum(self):
parsed = rbac_mod._AccessPolicyCreateRequest.model_validate(
{
"name": "Full access",
"resource_type": "app",
"description": "",
"permission_keys": [],
}
)
assert parsed.resource_type is rbac_mod.svc.RBACResourceType.APP
def test_access_policy_create_rejects_unknown_resource_type(self):
with pytest.raises(ValidationError):
rbac_mod._AccessPolicyCreateRequest.model_validate({"name": "bad", "resource_type": "unknown"})
def test_replace_bindings_defaults_empty(self):
parsed = rbac_mod._ReplaceBindingsRequest.model_validate({})
assert parsed.role_ids == []
assert parsed.account_ids == []
def test_replace_bindings_coerce_null_lists(self):
parsed = rbac_mod._ReplaceBindingsRequest.model_validate({"role_ids": None, "account_ids": None})
assert parsed.role_ids == []
assert parsed.account_ids == []
def test_replace_member_roles_coerce_null_list(self):
parsed = rbac_mod._ReplaceMemberRolesRequest.model_validate({"role_ids": None})
assert parsed.role_ids == []
def test_pagination_query_accepts_page_and_limit_aliases(self):
parsed = rbac_mod._PaginationQuery.model_validate({"page": 3, "limit": 25, "reverse": True})
assert parsed.page_number == 3
assert parsed.results_per_page == 25
assert parsed.reverse is True
def test_pagination_query_accepts_legacy_inner_names(self):
parsed = rbac_mod._PaginationQuery.model_validate(
{"page_number": 4, "results_per_page": 30, "reverse": False}
)
assert parsed.page_number == 4
assert parsed.results_per_page == 30
assert parsed.reverse is False
class TestPaginationMapping:
def test_roles_get_returns_legacy_compatible_roles_when_rbac_disabled(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles?page=1&limit=2&include_owner=1"),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list,
):
response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
assert response["data"] == [
{
"id": "owner",
"tenant_id": "",
"type": "workspace",
"category": "global_system_default",
"name": "owner",
"description": "",
"is_builtin": True,
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]),
"role_tag": "owner",
},
{
"id": "admin",
"tenant_id": "",
"type": "workspace",
"category": "global_system_default",
"name": "admin",
"description": "",
"is_builtin": True,
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]),
"role_tag": "",
},
]
assert response["pagination"] == {
"total_count": 5,
"per_page": 2,
"current_page": 1,
"total_pages": 3,
}
mock_list.assert_not_called()
def test_roles_get_filters_out_owner_when_include_owner_is_zero(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles?include_owner=0"),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"),
):
response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
names = [r["name"] for r in response["data"]]
assert "owner" not in names
def test_roles_get_keeps_owner_when_include_owner_is_one(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles?include_owner=1"),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"),
):
response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
names = [r["name"] for r in response["data"]]
assert "owner" in names
def test_roles_get_filters_out_owner_by_default(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles"),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"),
):
response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
names = [r["name"] for r in response["data"]]
assert "owner" not in names
def test_roles_get_forwards_outer_pagination_params(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles?page=2&limit=50&reverse=true&include_owner=1"),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", True),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
_, kwargs = mock_list.call_args
options = kwargs["options"]
assert options.page_number == 2
assert options.results_per_page == 50
assert options.reverse is True
def test_access_policies_get_forwards_outer_pagination_params(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/access-policies?resource_type=app&page=3&limit=25&reverse=false"
),
_enabled(True),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.list") as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACAccessPoliciesApi.get)(rbac_mod.RBACAccessPoliciesApi())
_, kwargs = mock_list.call_args
assert kwargs["resource_type"] == "app"
options = kwargs["options"]
assert options.page_number == 3
assert options.results_per_page == 25
assert options.reverse is False
def test_workspace_app_matrix_forwards_outer_pagination_params(self, app):
with (
app.test_request_context("/workspaces/current/rbac/workspace/apps/access-policy?page=4&limit=10"),
_enabled(True),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.app_matrix") as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACWorkspaceAppMatrixApi.get)(rbac_mod.RBACWorkspaceAppMatrixApi())
_, kwargs = mock_list.call_args
options = kwargs["options"]
assert options.page_number == 4
assert options.results_per_page == 10
assert options.reverse is None
def test_workspace_dataset_matrix_forwards_outer_pagination_params(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/workspace/datasets/access-policy?page=5&limit=15&reverse=true"
),
_enabled(True),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.dataset_matrix")
as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACWorkspaceDatasetMatrixApi.get)(rbac_mod.RBACWorkspaceDatasetMatrixApi())
_, kwargs = mock_list.call_args
options = kwargs["options"]
assert options.page_number == 5
assert options.results_per_page == 15
assert options.reverse is True
class TestRoleCopy:
def test_role_copy_forwards_path_id(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles/role-1/copy", method="POST"),
_enabled(True),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.copy") as mock_copy,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACRoleCopyApi.post)(rbac_mod.RBACRoleCopyApi(), "role-1")
mock_copy.assert_called_once_with("tenant-1", "acct-1", "role-1")
class TestDumpHelper:
def test_dump_returns_plain_dict(self):
role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", name="Owner")
dumped = rbac_mod._dump(role)
assert isinstance(dumped, dict)
assert "role_id" not in dumped

View File

@ -13,7 +13,6 @@ import base64
import secrets
from datetime import UTC, datetime
from uuid import uuid4
from unittest.mock import patch
import pytest
@ -348,15 +347,7 @@ class TestAccountRolePermissions:
account.role = TenantAccountRole.ADMIN
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin_or_owner
assert account.is_admin_or_owner
def test_is_admin_or_owner_with_owner_role(self):
"""Test is_admin_or_owner property with owner role."""
@ -392,16 +383,8 @@ class TestAccountRolePermissions:
owner_account.role = TenantAccountRole.OWNER
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert admin_account.is_admin
assert not owner_account.is_admin
def test_is_admin_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_admin
assert admin_account.is_admin
assert not owner_account.is_admin
def test_has_edit_permission_with_editing_roles(self):
"""Test has_edit_permission property with roles that have edit permission."""
@ -417,15 +400,7 @@ class TestAccountRolePermissions:
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.has_edit_permission, f"Role {role} should have edit permission"
def test_has_edit_permission_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.has_edit_permission
assert account.has_edit_permission, f"Role {role} should have edit permission"
def test_has_edit_permission_without_editing_roles(self):
"""Test has_edit_permission property with roles that don't have edit permission."""
@ -440,8 +415,7 @@ class TestAccountRolePermissions:
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
def test_is_dataset_editor_property(self):
"""Test is_dataset_editor property."""
@ -458,21 +432,12 @@ class TestAccountRolePermissions:
account.role = role
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
# Test normal role doesn't have dataset edit permission
normal_account = Account(name="Normal User", email="normal@example.com")
normal_account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert not normal_account.is_dataset_editor
def test_is_dataset_editor_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_editor
assert not normal_account.is_dataset_editor
def test_is_dataset_operator_property(self):
"""Test is_dataset_operator property."""
@ -484,16 +449,8 @@ class TestAccountRolePermissions:
normal_account.role = TenantAccountRole.NORMAL
# Act & Assert
with patch("models.account.dify_config.RBAC_ENABLED", False):
assert dataset_operator.is_dataset_operator
assert not normal_account.is_dataset_operator
def test_is_dataset_operator_with_rbac_enabled(self):
account = Account(name="Test User", email="test@example.com")
account.role = TenantAccountRole.NORMAL
with patch("models.account.dify_config.RBAC_ENABLED", True):
assert account.is_dataset_operator
assert dataset_operator.is_dataset_operator
assert not normal_account.is_dataset_operator
def test_current_role_property(self):
"""Test current_role property."""

View File

@ -1,568 +0,0 @@
"""Unit tests for services.enterprise.rbac_service.
The enterprise RBAC client is almost pure glue: each method turns a single
``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response
model. Rather than spinning up an HTTP server we monkeypatch that helper and
assert on the arguments it received; that catches both routing regressions
(wrong method / wrong path / wrong params) and model-shape regressions in
one place.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from services.enterprise import rbac_service as svc
MODULE = "services.enterprise.rbac_service"
@pytest.fixture
def mock_send():
with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send:
yield send
def _call_args(send: MagicMock) -> SimpleNamespace:
"""Return the most recent (method, endpoint, kwargs) sent to the mock."""
send.assert_called_once()
args, kwargs = send.call_args
return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs)
class TestCatalog:
def test_workspace_catalog(self, mock_send: MagicMock):
mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]}
out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/role-permissions/catalog"
assert call.tenant_id == "tenant-1"
assert call.account_id == "acct-1"
assert call.json is None
assert call.params is None
assert len(out.groups) == 1
assert out.groups[0].group_key == "workspace"
def test_app_catalog_endpoint(self, mock_send: MagicMock):
mock_send.return_value = {"groups": []}
svc.RBACService.Catalog.app("tenant-1")
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app"
def test_dataset_catalog_endpoint(self, mock_send: MagicMock):
mock_send.return_value = {"groups": []}
svc.RBACService.Catalog.dataset("tenant-1")
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset"
class TestRoles:
def test_list_forwards_pagination_options(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "role-1",
"tenant_id": "tenant-1",
"type": "workspace",
"category": "global_custom",
"name": "Owner",
"permission_keys": ["workspace.member.manage"],
}
],
"pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1},
}
out = svc.RBACService.Roles.list(
"tenant-1",
"acct-1",
options=svc.ListOption(page_number=2, results_per_page=50, reverse=True),
)
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles"
assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
assert out.pagination and out.pagination.total_count == 1
def test_list_omits_params_when_default(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1")
assert _call_args(mock_send).params is None
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "role-1",
"tenant_id": "tenant-1",
"type": "workspace",
"category": "global_custom",
"name": "Owner",
"permission_keys": None,
}
],
"pagination": None,
}
out = svc.RBACService.Roles.list("tenant-1")
assert out.data[0].permission_keys == []
def test_get_passes_id_query_param(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
def test_create_sends_body(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"])
svc.RBACService.Roles.create("tenant-1", "acct-1", payload)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/roles"
assert call.json == {
"name": "Owner",
"description": "full access",
"permission_keys": ["workspace.member.manage"],
"type": "workspace",
}
def test_update_sends_id_param_and_body(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
payload = svc.RoleMutation(name="Owner", permission_keys=["x"])
svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"}
def test_delete_uses_delete_method(self, mock_send: MagicMock):
mock_send.return_value = {"message": "success"}
svc.RBACService.Roles.delete("tenant-1", None, "role-1")
call = _call_args(mock_send)
assert call.method == "DELETE"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
assert call.account_id is None
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"}
svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1")
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/roles/copy"
assert call.params == {"id": "role-1"}
assert call.account_id == "acct-1"
class TestAccessPolicies:
def test_list_filters_by_resource_type(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.AccessPolicies.list(
"tenant-1",
"acct-1",
resource_type=svc.RBACResourceType.APP,
options=svc.ListOption(page_number=1),
)
call = _call_args(mock_send)
assert call.endpoint == "/rbac/access-policies"
assert call.params == {"page_number": 1, "resource_type": "app"}
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
mock_send.return_value = {
"id": "policy-1-copy",
"resource_type": "app",
"name": "Full access copy",
}
svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1")
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/access-policies/copy"
assert call.params == {"id": "policy-1"}
def test_create_serialises_resource_type_enum(self, mock_send: MagicMock):
mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"}
payload = svc.AccessPolicyCreate(
name="KB only",
resource_type=svc.RBACResourceType.DATASET,
permission_keys=["dataset.acl.readonly"],
)
svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.json == {
"name": "KB only",
"resource_type": "dataset",
"description": "",
"permission_keys": ["dataset.acl.readonly"],
}
class TestResourceAccess:
def test_app_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"app_id": "app-1", "items": []}
out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/apps/access-policy"
assert call.params == {"app_id": "app-1"}
assert out.app_id == "app-1"
def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "binding-1",
"tenant_id": "tenant-1",
"access_policy_id": "policy-1",
"resource_type": "app",
"resource_id": "app-1",
"role_id": "role-1",
"role_name": "Owner",
}
]
}
out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1")
assert out.data[0].role_name == "Owner"
def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "binding-1",
"tenant_id": "tenant-1",
"access_policy_id": "policy-1",
"resource_type": "app",
"resource_id": "app-1",
"account_id": "acct-1",
"account_name": "Alice",
}
]
}
out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1")
assert out.data[0].account_name == "Alice"
def test_app_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"])
svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/apps/access-policy/bindings"
assert call.params == {"app_id": "app-1", "policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]}
def test_dataset_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/datasets/access-policy/bindings"
assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
class TestWorkspaceAccess:
def test_app_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"items": [], "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}}
out = svc.RBACService.WorkspaceAccess.app_matrix(
"tenant-1",
options=svc.ListOption(page_number=2, results_per_page=20),
)
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/workspace/apps/access-policy"
assert call.params == {"page_number": 2, "results_per_page": 20}
assert out.pagination and out.pagination.current_page == 2
def test_dataset_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"items": []}
svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/workspace/datasets/access-policy"
assert call.params is None
def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock):
mock_send.return_value = {
"items": [
{
"policy": {
"id": "policy-1",
"resource_type": "app",
"name": "Workspace App Access",
},
"roles": None,
"accounts": None,
}
],
"pagination": None,
}
out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1")
assert out.items[0].roles == []
assert out.items[0].accounts == []
def test_workspace_app_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.WorkspaceAccess.replace_app_bindings(
"tenant-1", "acct-1", "policy-1", payload
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings"
assert call.params == {"policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.WorkspaceAccess.replace_dataset_bindings(
"tenant-1", "acct-1", "policy-1", payload
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings"
assert call.params == {"policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
class TestMyPermissions:
def test_get_without_payload_uses_get(self, mock_send: MagicMock):
mock_send.return_value = {
"workspace": {"permission_keys": ["workspace.member.manage"]},
"app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []},
"dataset": {"default_permission_keys": [], "overrides": []},
}
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/my-permissions"
assert call.json is None
assert call.params is None
assert out.workspace.permission_keys == ["workspace.member.manage"]
@pytest.mark.parametrize(
("role", "workspace_keys", "app_keys", "dataset_keys"),
[
(
"owner",
svc._LEGACY_WORKSPACE_OWNER_KEYS,
svc._LEGACY_APP_OWNER_KEYS,
svc._LEGACY_DATASET_OWNER_KEYS,
),
(
"admin",
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
svc._LEGACY_APP_ADMIN_KEYS,
svc._LEGACY_DATASET_ADMIN_KEYS,
),
(
"editor",
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
svc._LEGACY_APP_EDITOR_KEYS,
svc._LEGACY_DATASET_EDITOR_KEYS,
),
(
"normal",
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
svc._LEGACY_APP_NORMAL_KEYS,
[],
),
(
"dataset_operator",
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
[],
svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
),
],
)
def test_get_uses_legacy_role_permissions_when_rbac_disabled(
self,
mock_send: MagicMock,
role: str,
workspace_keys: list[str],
app_keys: list[str],
dataset_keys: list[str],
):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = role
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
mock_send.assert_not_called()
assert out.workspace.permission_keys == workspace_keys
assert out.app.default_permission_keys == app_keys
assert out.dataset.default_permission_keys == dataset_keys
assert out.app.overrides == []
assert out.dataset.overrides == []
def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = None
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
mock_send.assert_not_called()
assert out.workspace.permission_keys == []
assert out.app.default_permission_keys == []
assert out.dataset.default_permission_keys == []
def test_get_with_single_resource_filters(self, mock_send: MagicMock):
mock_send.return_value = {
"workspace": {"permission_keys": []},
"app": {"default_permission_keys": [], "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}]},
"dataset": {"default_permission_keys": [], "overrides": []},
}
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/my-permissions"
assert call.params == {"app_id": "app-1"}
assert out.app.overrides[0].resource_id == "app-1"
class TestMemberRoles:
def test_get(self, mock_send: MagicMock):
mock_send.return_value = {
"account_id": "acct-2",
"roles": [
{
"id": "role-1",
"type": "workspace",
"name": "Member",
}
],
}
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/members/rbac-roles"
assert call.params == {"account_id": "acct-2"}
assert out.account_id == "acct-2"
assert out.roles[0].name == "Member"
def test_replace(self, mock_send: MagicMock):
mock_send.return_value = {"account_id": "acct-2", "roles": []}
svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"]
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/members/rbac-roles"
assert call.params == {"account_id": "acct-2"}
assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]}
def test_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"account_id": "acct-2",
"roles": [
{"id": "role-1", "name": "Admin"},
{"id": "role-2", "name": "Editor"},
],
},
{
"account_id": "acct-3",
"roles": [],
},
]
}
out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/members/rbac-roles/batch"
assert call.json == {"account_ids": ["acct-2", "acct-3"]}
assert out[0].account_id == "acct-2"
assert len(out[0].roles) == 2
class TestResourcePermissions:
def test_app_permissions_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]},
{"resource_id": "app-2", "permission_keys": []},
]
}
out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/apps/permission-keys/batch"
assert call.json == {"app_ids": ["app-1", "app-2"]}
assert out == {
"app-1": ["app.acl.view_layout", "app.acl.edit"],
"app-2": [],
}
def test_dataset_permissions_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]},
{"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]},
]
}
out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/datasets/permission-keys/batch"
assert call.json == {"dataset_ids": ["ds-1", "ds-2"]}
assert out == {
"ds-1": ["dataset.acl.readonly"],
"ds-2": ["dataset.acl.edit"],
}
class TestListOption:
def test_empty_produces_empty_params(self):
assert svc.ListOption().to_params() == {}
def test_reverse_serialises_as_lowercase_bool(self):
assert svc.ListOption(reverse=False).to_params()["reverse"] == "false"
assert svc.ListOption(reverse=True).to_params()["reverse"] == "true"
def test_extra_overrides_merge(self):
assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == {
"page_number": 1,
"resource_type": "app",
}

View File

@ -849,40 +849,6 @@ class TestTenantService:
assert mock_target_join.role == "admin"
self._assert_database_operations_called(mock_db)
def test_create_owner_tenant_if_not_exist_rbac_enabled_assigns_owner_role(
self, mock_db_dependencies, mock_external_service_dependencies
):
mock_account = TestAccountAssociatedDataFactory.create_account_mock(account_id="user-rbac", name="RBAC User")
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_tenant = MagicMock()
mock_tenant.id = "tenant-rbac"
mock_tenant.name = "RBAC User's Workspace"
with (
patch("services.account_service.dify_config.RBAC_ENABLED", True),
patch("services.account_service.TenantService.create_tenant", return_value=mock_tenant),
patch("services.account_service.TenantService.create_tenant_member"),
patch("services.account_service.AccountService.resolve_workspace_rbac_role_id", return_value="rbac-owner-id"),
patch("services.account_service.RBACService") as mock_rbac_service,
patch("services.account_service.tenant_was_created.send"),
):
mock_db_dependencies["db"].session.scalar.return_value = None
TenantService.create_owner_tenant_if_not_exist(mock_account, is_setup=True)
mock_rbac_service.MemberRoles.replace.assert_called_once_with(
tenant_id="tenant-rbac",
account_id="user-rbac",
member_account_id="user-rbac",
role_ids=["rbac-owner-id"],
)
def test_admin_can_update_admin_member_role(self):
"""Test admin can update another non-owner member, including an admin."""
mock_tenant = MagicMock()
@ -1757,138 +1723,6 @@ class TestRegisterService:
inviter=None,
)
# ==================== RBAC Member Invitation Tests ====================
def test_invite_new_member_rbac_enabled_new_account(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
):
"""When RBAC is enabled, create_tenant_member should be skipped and MemberRoles.replace called."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-789"
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter")
with (
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
patch("services.account_service.dify_config") as mock_config,
):
mock_lookup.return_value = None
mock_config.RBAC_ENABLED = True
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
account_id="new-user-rbac", email="rbac@example.com", name="rbacuser", status="pending"
)
with (
patch("services.account_service.RegisterService.register") as mock_register,
patch("services.account_service.TenantService.check_member_permission"),
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
patch("services.account_service.TenantService.switch_tenant"),
patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"),
patch("services.account_service.AccountService.resolve_workspace_rbac_role_id", return_value="rbac-role-id-123"),
patch("services.account_service.RBACService") as mock_rbac_service,
):
mock_register.return_value = mock_new_account
result = RegisterService.invite_new_member(
tenant=mock_tenant,
email="rbac@example.com",
language="en-US",
role="rbac-role-id-123",
inviter=mock_inviter,
)
assert result == "rbac-token"
mock_create_member.assert_not_called()
mock_rbac_service.MemberRoles.replace.assert_called_once_with(
tenant_id=str(mock_tenant.id),
account_id=mock_inviter.id,
member_account_id=mock_new_account.id,
role_ids=["rbac-role-id-123"],
)
def test_invite_new_member_rbac_enabled_existing_account(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
):
"""When RBAC is enabled and account exists, create_tenant_member should be skipped and MemberRoles.replace called."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-789"
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter")
mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock(
account_id="existing-rbac", email="existing-rbac@example.com", status="pending"
)
mock_db_dependencies["db"].session.scalar.return_value = None
with (
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
patch("services.account_service.dify_config") as mock_config,
):
mock_lookup.return_value = mock_existing_account
mock_config.RBAC_ENABLED = True
with (
patch("services.account_service.TenantService.check_member_permission"),
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"),
patch("services.account_service.AccountService.resolve_workspace_rbac_role_id", return_value="rbac-role-id-456"),
patch("services.account_service.RBACService") as mock_rbac_service,
):
result = RegisterService.invite_new_member(
tenant=mock_tenant,
email="existing-rbac@example.com",
language="en-US",
role="rbac-role-id-456",
inviter=mock_inviter,
)
assert result == "rbac-token"
mock_create_member.assert_not_called()
mock_rbac_service.MemberRoles.replace.assert_called_once_with(
tenant_id=str(mock_tenant.id),
account_id=mock_inviter.id,
member_account_id=mock_existing_account.id,
role_ids=["rbac-role-id-456"],
)
def test_invite_new_member_rbac_disabled_uses_legacy_role(
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
):
"""When RBAC is disabled, create_tenant_member should be called and MemberRoles.replace should NOT."""
mock_tenant = MagicMock()
mock_tenant.id = "tenant-legacy"
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-789", name="Inviter")
with (
patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup,
patch("services.account_service.dify_config") as mock_config,
):
mock_lookup.return_value = None
mock_config.RBAC_ENABLED = False
mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
account_id="legacy-user", email="legacy@example.com", name="legacyuser", status="pending"
)
with (
patch("services.account_service.RegisterService.register") as mock_register,
patch("services.account_service.TenantService.check_member_permission"),
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
patch("services.account_service.TenantService.switch_tenant"),
patch("services.account_service.RegisterService.generate_invite_token", return_value="legacy-token"),
patch("services.account_service.RBACService") as mock_rbac_service,
):
mock_register.return_value = mock_new_account
result = RegisterService.invite_new_member(
tenant=mock_tenant,
email="legacy@example.com",
language="en-US",
role="editor",
inviter=mock_inviter,
)
assert result == "legacy-token"
mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "editor")
mock_rbac_service.MemberRoles.replace.assert_not_called()
# ==================== Token Management Tests ====================
def test_generate_invite_token_success(self, mock_redis_dependencies):

View File

@ -119,11 +119,6 @@
"count": 3
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": {
"react/static-components": {
"count": 2
@ -435,11 +430,6 @@
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
"ts/no-explicit-any": {
"count": 1
@ -495,35 +485,6 @@
"count": 1
}
},
"web/app/components/app/overview/app-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/customize/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/embedded/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/app/overview/settings/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 3
},
"regexp/no-unused-capturing-group": {
"count": 1
}
},
"web/app/components/app/overview/trigger-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -856,21 +817,6 @@
"count": 1
}
},
"web/app/components/base/dialog/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/base/drawer-plus/index.stories.tsx": {
"react/component-hook-factories": {
"count": 1
}
},
"web/app/components/base/emoji-picker/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
@ -922,11 +868,6 @@
"count": 2
}
},
"web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/features/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -1718,7 +1659,6 @@
"web/app/components/base/text-generation/types.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
"count": 1
}
},
"web/app/components/base/textarea/index.stories.tsx": {
@ -1957,36 +1897,16 @@
"count": 4
}
},
"web/app/components/datasets/documents/components/operations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/components/rename-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": {
"react/set-state-in-effect": {
"count": 5
@ -2058,16 +1978,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/completed/components/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 3
}
},
"web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2094,11 +2004,6 @@
"count": 2
}
},
"web/app/components/datasets/documents/detail/completed/segment-card/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/context.ts": {
"ts/no-explicit-any": {
"count": 1
@ -2114,11 +2019,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
@ -2228,16 +2128,6 @@
"count": 2
}
},
"web/app/components/develop/secret-key/secret-key-generate.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/develop/secret-key/secret-key-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/explore/banner/banner-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -2437,14 +2327,6 @@
"count": 3
}
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 6
}
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx": {
"unicorn/prefer-number-properties": {
"count": 2
@ -2542,9 +2424,6 @@
}
},
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -2561,10 +2440,10 @@
},
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
}
@ -2678,24 +2557,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts": {
"erasable-syntax-only/enums": {
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -2724,7 +2585,7 @@
"count": 7
}
},
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": {
"web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": {
"no-restricted-imports": {
"count": 1
}
@ -2764,21 +2625,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-page/plugin-info.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/readme-panel/index.tsx": {
"react/unsupported-syntax": {
"count": 1
}
},
"web/app/components/plugins/readme-panel/store.ts": {
"erasable-syntax-only/enums": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -3025,19 +2871,6 @@
"count": 1
}
},
"web/app/components/tools/mcp/detail/tool-item.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/tools/mcp/mcp-server-modal.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 5
}
},
"web/app/components/tools/mcp/mcp-server-param-item.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3375,11 +3208,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/error-handle/types.ts": {
"erasable-syntax-only/enums": {
"count": 1
@ -3388,11 +3216,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/field.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3434,11 +3257,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/option-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": {
"ts/no-explicit-any": {
"count": 4
@ -3459,11 +3277,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": {
"ts/no-explicit-any": {
"count": 8
@ -3801,16 +3614,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/if-else/default.ts": {
"ts/no-explicit-any": {
"count": 1
@ -3851,11 +3654,6 @@
"count": 4
}
},
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": {
"ts/no-explicit-any": {
"count": 2
@ -3930,11 +3728,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -4006,16 +3799,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": {
"ts/no-explicit-any": {
"count": 3
@ -4060,9 +3843,6 @@
}
},
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -4226,11 +4006,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/trigger-schedule/components/monthly-days-selector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-schedule/default.ts": {
"regexp/no-unused-capturing-group": {
"count": 2
@ -4249,11 +4024,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/trigger-webhook/panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/utils.ts": {
"ts/no-explicit-any": {
"count": 1
@ -4282,6 +4052,9 @@
"web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx": {
"no-restricted-imports": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/workflow/note-node/note-editor/utils.ts": {
@ -4674,14 +4447,6 @@
"count": 1
}
},
"web/app/signin/one-more-step.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/signup/layout.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

@ -556,8 +556,28 @@ export type WorkflowRunNodeExecutionListResponse = {
data: Array<WorkflowRunNodeExecutionResponse>
}
export type WorkflowCommentBasicList = {
data: Array<WorkflowCommentBasic>
export type WorkflowCommentBasic = {
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
mention_count?: number
participants?: Array<AnonymousInlineModel6Fec07Cd0D85>
position_x?: number
position_y?: number
reply_count?: number
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
resolved_by_account?: AnonymousInlineModel6Fec07Cd0D85
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentCreatePayload = {
@ -568,8 +588,10 @@ export type WorkflowCommentCreatePayload = {
}
export type WorkflowCommentCreate = {
created_at?: number | null
id: string
created_at?: {
[key: string]: unknown
}
id?: string
}
export type WorkflowCommentMentionUsersPayload = {
@ -577,20 +599,26 @@ export type WorkflowCommentMentionUsersPayload = {
}
export type WorkflowCommentDetail = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
mentions: Array<WorkflowCommentMention>
position_x: number
position_y: number
replies: Array<WorkflowCommentReply>
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccount
updated_at?: number | null
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
mentions?: Array<AnonymousInlineModelF7Ff64Cce858>
position_x?: number
position_y?: number
replies?: Array<AnonymousInlineModel55C39C6A4B9e>
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
resolved_by_account?: AnonymousInlineModel6Fec07Cd0D85
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentUpdatePayload = {
@ -601,8 +629,10 @@ export type WorkflowCommentUpdatePayload = {
}
export type WorkflowCommentUpdate = {
id: string
updated_at?: number | null
id?: string
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentReplyPayload = {
@ -611,20 +641,26 @@ export type WorkflowCommentReplyPayload = {
}
export type WorkflowCommentReplyCreate = {
created_at?: number | null
id: string
created_at?: {
[key: string]: unknown
}
id?: string
}
export type WorkflowCommentReplyUpdate = {
id: string
updated_at?: number | null
id?: string
updated_at?: {
[key: string]: unknown
}
}
export type WorkflowCommentResolve = {
id: string
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
id?: string
resolved?: boolean
resolved_at?: {
[key: string]: unknown
}
resolved_by?: string
}
export type WorkflowPagination = {
@ -1131,22 +1167,13 @@ export type SimpleEndUser = {
type: string
}
export type WorkflowCommentBasic = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
mention_count: number
participants: Array<WorkflowCommentAccount>
position_x: number
position_y: number
reply_count: number
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccount
updated_at?: number | null
export type AnonymousInlineModel6Fec07Cd0D85 = {
avatar_url?: {
[key: string]: unknown
}
email?: string
id?: string
name?: string
}
export type AccountWithRole = {
@ -1161,25 +1188,20 @@ export type AccountWithRole = {
status: string
}
export type WorkflowCommentAccount = {
readonly avatar_url: string | null
email: string
id: string
name: string
export type AnonymousInlineModelF7Ff64Cce858 = {
mentioned_user_account?: AnonymousInlineModel6Fec07Cd0D85
mentioned_user_id?: string
reply_id?: string
}
export type WorkflowCommentMention = {
mentioned_user_account?: WorkflowCommentAccount
mentioned_user_id: string
reply_id?: string | null
}
export type WorkflowCommentReply = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccount
id: string
export type AnonymousInlineModel55C39C6A4B9e = {
content?: string
created_at?: {
[key: string]: unknown
}
created_by?: string
created_by_account?: AnonymousInlineModel6Fec07Cd0D85
id?: string
}
export type ConversationVariable = {
@ -1327,7 +1349,11 @@ export type UserActionConfig = {
title: string
}
export type FormInputConfig = unknown
export type FormInputConfig
= | ParagraphInputConfig
| SelectInputConfig
| FileInputConfig
| FileListInputConfig
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
@ -1378,65 +1404,6 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type WorkflowCommentBasicListWritable = {
data: Array<WorkflowCommentBasicWritable>
}
export type WorkflowCommentDetailWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
mentions: Array<WorkflowCommentMentionWritable>
position_x: number
position_y: number
replies: Array<WorkflowCommentReplyWritable>
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccountWritable
updated_at?: number | null
}
export type WorkflowCommentBasicWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
mention_count: number
participants: Array<WorkflowCommentAccountWritable>
position_x: number
position_y: number
reply_count: number
resolved: boolean
resolved_at?: number | null
resolved_by?: string | null
resolved_by_account?: WorkflowCommentAccountWritable
updated_at?: number | null
}
export type WorkflowCommentAccountWritable = {
email: string
id: string
name: string
}
export type WorkflowCommentMentionWritable = {
mentioned_user_account?: WorkflowCommentAccountWritable
mentioned_user_id: string
reply_id?: string | null
}
export type WorkflowCommentReplyWritable = {
content: string
created_at?: number | null
created_by: string
created_by_account?: WorkflowCommentAccountWritable
id: string
}
export type GetAppsData = {
body?: never
path?: never
@ -3595,7 +3562,7 @@ export type GetAppsByAppIdWorkflowCommentsData = {
}
export type GetAppsByAppIdWorkflowCommentsResponses = {
200: WorkflowCommentBasicList
200: WorkflowCommentBasic
}
export type GetAppsByAppIdWorkflowCommentsResponse

View File

@ -373,12 +373,9 @@ export const zWorkflowCommentCreatePayload = z.object({
position_y: z.number(),
})
/**
* WorkflowCommentCreate
*/
export const zWorkflowCommentCreate = z.object({
created_at: z.int().nullish(),
id: z.string(),
created_at: z.record(z.string(), z.unknown()).optional(),
id: z.string().optional(),
})
/**
@ -391,12 +388,9 @@ export const zWorkflowCommentUpdatePayload = z.object({
position_y: z.number().nullish(),
})
/**
* WorkflowCommentUpdate
*/
export const zWorkflowCommentUpdate = z.object({
id: z.string(),
updated_at: z.int().nullish(),
id: z.string().optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
@ -407,30 +401,21 @@ export const zWorkflowCommentReplyPayload = z.object({
mentioned_user_ids: z.array(z.string()).optional(),
})
/**
* WorkflowCommentReplyCreate
*/
export const zWorkflowCommentReplyCreate = z.object({
created_at: z.int().nullish(),
id: z.string(),
created_at: z.record(z.string(), z.unknown()).optional(),
id: z.string().optional(),
})
/**
* WorkflowCommentReplyUpdate
*/
export const zWorkflowCommentReplyUpdate = z.object({
id: z.string(),
updated_at: z.int().nullish(),
id: z.string().optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
* WorkflowCommentResolve
*/
export const zWorkflowCommentResolve = z.object({
id: z.string(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
id: z.string().optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
})
/**
@ -1008,6 +993,31 @@ export const zWorkflowRunNodeExecutionListResponse = z.object({
data: z.array(zWorkflowRunNodeExecutionResponse),
})
export const zAnonymousInlineModel6Fec07Cd0D85 = z.object({
avatar_url: z.record(z.string(), z.unknown()).optional(),
email: z.string().optional(),
id: z.string().optional(),
name: z.string().optional(),
})
export const zWorkflowCommentBasic = z.object({
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
mention_count: z.int().optional(),
participants: z.array(zAnonymousInlineModel6Fec07Cd0D85).optional(),
position_x: z.number().optional(),
position_y: z.number().optional(),
reply_count: z.int().optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
resolved_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
/**
* AccountWithRole
*/
@ -1030,82 +1040,35 @@ export const zWorkflowCommentMentionUsersPayload = z.object({
users: z.array(zAccountWithRole),
})
/**
* WorkflowCommentAccount
*/
export const zWorkflowCommentAccount = z.object({
avatar_url: z.string().readonly().nullable(),
email: z.string(),
id: z.string(),
name: z.string(),
export const zAnonymousInlineModelF7Ff64Cce858 = z.object({
mentioned_user_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
mentioned_user_id: z.string().optional(),
reply_id: z.string().optional(),
})
/**
* WorkflowCommentBasic
*/
export const zWorkflowCommentBasic = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
mention_count: z.int(),
participants: z.array(zWorkflowCommentAccount),
position_x: z.number(),
position_y: z.number(),
reply_count: z.int(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccount.optional(),
updated_at: z.int().nullish(),
export const zAnonymousInlineModel55C39C6A4B9e = z.object({
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
})
/**
* WorkflowCommentBasicList
*/
export const zWorkflowCommentBasicList = z.object({
data: z.array(zWorkflowCommentBasic),
})
/**
* WorkflowCommentMention
*/
export const zWorkflowCommentMention = z.object({
mentioned_user_account: zWorkflowCommentAccount.optional(),
mentioned_user_id: z.string(),
reply_id: z.string().nullish(),
})
/**
* WorkflowCommentReply
*/
export const zWorkflowCommentReply = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
})
/**
* WorkflowCommentDetail
*/
export const zWorkflowCommentDetail = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccount.optional(),
id: z.string(),
mentions: z.array(zWorkflowCommentMention),
position_x: z.number(),
position_y: z.number(),
replies: z.array(zWorkflowCommentReply),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccount.optional(),
updated_at: z.int().nullish(),
content: z.string().optional(),
created_at: z.record(z.string(), z.unknown()).optional(),
created_by: z.string().optional(),
created_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
id: z.string().optional(),
mentions: z.array(zAnonymousInlineModelF7Ff64Cce858).optional(),
position_x: z.number().optional(),
position_y: z.number().optional(),
replies: z.array(zAnonymousInlineModel55C39C6A4B9e).optional(),
resolved: z.boolean().optional(),
resolved_at: z.record(z.string(), z.unknown()).optional(),
resolved_by: z.string().optional(),
resolved_by_account: zAnonymousInlineModel6Fec07Cd0D85.optional(),
updated_at: z.record(z.string(), z.unknown()).optional(),
})
export const zConversationVariable = z.object({
@ -1561,8 +1524,6 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({
total: z.int(),
})
export const zFormInputConfig = z.unknown()
/**
* ButtonStyle
*
@ -1581,72 +1542,6 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* HumanInputFormDefinition
*/
export const zHumanInputFormDefinition = z.object({
actions: z.array(zUserActionConfig).optional(),
display_in_ui: z.boolean().optional().default(false),
expiration_time: z.int(),
form_content: z.string(),
form_id: z.string(),
form_token: z.string().nullish(),
inputs: z.array(zFormInputConfig).optional(),
node_id: z.string(),
node_title: z.string(),
resolved_default_values: z.record(z.string(), z.unknown()).optional(),
})
/**
* HumanInputContent
*/
export const zHumanInputContent = z.object({
form_definition: zHumanInputFormDefinition.optional(),
form_submission_data: zHumanInputFormSubmissionData.optional(),
submitted: z.boolean(),
type: zExecutionContentType.optional(),
workflow_run_id: z.string(),
})
/**
* MessageDetailResponse
*/
export const zMessageDetailResponse = z.object({
agent_thoughts: z.array(zAgentThought).optional(),
annotation: zConversationAnnotation.optional(),
annotation_hit_history: zConversationAnnotationHitHistory.optional(),
answer_tokens: z.int().nullish(),
conversation_id: z.string(),
created_at: z.int().nullish(),
error: z.string().nullish(),
extra_contents: z.array(zHumanInputContent).optional(),
feedbacks: z.array(zFeedback).optional(),
from_account_id: z.string().nullish(),
from_end_user_id: z.string().nullish(),
from_source: z.string(),
id: z.string(),
inputs: z.record(z.string(), zJsonValue),
message: zJsonValue.optional(),
message_files: z.array(zMessageFile).optional(),
message_metadata_dict: zJsonValue.optional(),
message_tokens: z.int().nullish(),
parent_message_id: z.string().nullish(),
provider_response_latency: z.number().nullish(),
query: z.string(),
re_sign_file_url_answer: z.string(),
status: z.string(),
workflow_run_id: z.string().nullish(),
})
/**
* MessageInfiniteScrollPaginationResponse
*/
export const zMessageInfiniteScrollPaginationResponse = z.object({
data: z.array(zMessageDetailResponse),
has_more: z.boolean(),
limit: z.int(),
})
/**
* FileType
*/
@ -1733,81 +1628,77 @@ export const zSelectInputConfig = z.object({
type: z.string().optional().default('select'),
})
export const zFormInputConfig = z.union([
zParagraphInputConfig,
zSelectInputConfig,
zFileInputConfig,
zFileListInputConfig,
])
/**
* WorkflowCommentAccount
* HumanInputFormDefinition
*/
export const zWorkflowCommentAccountWritable = z.object({
email: z.string(),
id: z.string(),
name: z.string(),
export const zHumanInputFormDefinition = z.object({
actions: z.array(zUserActionConfig).optional(),
display_in_ui: z.boolean().optional().default(false),
expiration_time: z.int(),
form_content: z.string(),
form_id: z.string(),
form_token: z.string().nullish(),
inputs: z.array(zFormInputConfig).optional(),
node_id: z.string(),
node_title: z.string(),
resolved_default_values: z.record(z.string(), z.unknown()).optional(),
})
/**
* WorkflowCommentBasic
* HumanInputContent
*/
export const zWorkflowCommentBasicWritable = z.object({
content: z.string(),
export const zHumanInputContent = z.object({
form_definition: zHumanInputFormDefinition.optional(),
form_submission_data: zHumanInputFormSubmissionData.optional(),
submitted: z.boolean(),
type: zExecutionContentType.optional(),
workflow_run_id: z.string(),
})
/**
* MessageDetailResponse
*/
export const zMessageDetailResponse = z.object({
agent_thoughts: z.array(zAgentThought).optional(),
annotation: zConversationAnnotation.optional(),
annotation_hit_history: zConversationAnnotationHitHistory.optional(),
answer_tokens: z.int().nullish(),
conversation_id: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
error: z.string().nullish(),
extra_contents: z.array(zHumanInputContent).optional(),
feedbacks: z.array(zFeedback).optional(),
from_account_id: z.string().nullish(),
from_end_user_id: z.string().nullish(),
from_source: z.string(),
id: z.string(),
mention_count: z.int(),
participants: z.array(zWorkflowCommentAccountWritable),
position_x: z.number(),
position_y: z.number(),
reply_count: z.int(),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccountWritable.optional(),
updated_at: z.int().nullish(),
inputs: z.record(z.string(), zJsonValue),
message: zJsonValue.optional(),
message_files: z.array(zMessageFile).optional(),
message_metadata_dict: zJsonValue.optional(),
message_tokens: z.int().nullish(),
parent_message_id: z.string().nullish(),
provider_response_latency: z.number().nullish(),
query: z.string(),
re_sign_file_url_answer: z.string(),
status: z.string(),
workflow_run_id: z.string().nullish(),
})
/**
* WorkflowCommentBasicList
* MessageInfiniteScrollPaginationResponse
*/
export const zWorkflowCommentBasicListWritable = z.object({
data: z.array(zWorkflowCommentBasicWritable),
})
/**
* WorkflowCommentMention
*/
export const zWorkflowCommentMentionWritable = z.object({
mentioned_user_account: zWorkflowCommentAccountWritable.optional(),
mentioned_user_id: z.string(),
reply_id: z.string().nullish(),
})
/**
* WorkflowCommentReply
*/
export const zWorkflowCommentReplyWritable = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
id: z.string(),
})
/**
* WorkflowCommentDetail
*/
export const zWorkflowCommentDetailWritable = z.object({
content: z.string(),
created_at: z.int().nullish(),
created_by: z.string(),
created_by_account: zWorkflowCommentAccountWritable.optional(),
id: z.string(),
mentions: z.array(zWorkflowCommentMentionWritable),
position_x: z.number(),
position_y: z.number(),
replies: z.array(zWorkflowCommentReplyWritable),
resolved: z.boolean(),
resolved_at: z.int().nullish(),
resolved_by: z.string().nullish(),
resolved_by_account: zWorkflowCommentAccountWritable.optional(),
updated_at: z.int().nullish(),
export const zMessageInfiniteScrollPaginationResponse = z.object({
data: z.array(zMessageDetailResponse),
has_more: z.boolean(),
limit: z.int(),
})
export const zGetAppsQuery = z.object({
@ -2902,7 +2793,7 @@ export const zGetAppsByAppIdWorkflowCommentsPath = z.object({
/**
* Comments retrieved successfully
*/
export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasicList
export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasic
export const zPostAppsByAppIdWorkflowCommentsBody = zWorkflowCommentCreatePayload

View File

@ -13,7 +13,6 @@ export type EmailCodeLoginPayload = {
code: string
email: string
language?: string | null
timezone?: string | null
token: string
}

View File

@ -17,7 +17,6 @@ export const zEmailCodeLoginPayload = z.object({
code: z.string(),
email: z.string(),
language: z.string().nullish(),
timezone: z.string().nullish(),
token: z.string(),
})

View File

@ -16,10 +16,6 @@ export type TagBasePayload = {
type: TagType
}
export type TagUpdateRequestPayload = {
name: string
}
export type TagType = 'app' | 'knowledge'
export type GetTagsData = {
@ -71,7 +67,7 @@ export type DeleteTagsByTagIdResponses = {
export type DeleteTagsByTagIdResponse = DeleteTagsByTagIdResponses[keyof DeleteTagsByTagIdResponses]
export type PatchTagsByTagIdData = {
body: TagUpdateRequestPayload
body: TagBasePayload
path: {
tag_id: string
}

View File

@ -12,13 +12,6 @@ export const zTagResponse = z.object({
type: z.string().nullish(),
})
/**
* TagUpdateRequestPayload
*/
export const zTagUpdateRequestPayload = z.object({
name: z.string().min(1).max(50),
})
/**
* TagType
*
@ -60,7 +53,7 @@ export const zDeleteTagsByTagIdPath = z.object({
*/
export const zDeleteTagsByTagIdResponse = z.record(z.string(), z.unknown())
export const zPatchTagsByTagIdBody = zTagUpdateRequestPayload
export const zPatchTagsByTagIdBody = zTagBasePayload
export const zPatchTagsByTagIdPath = z.object({
tag_id: z.string(),

View File

@ -563,6 +563,16 @@ const createApiConfig = (job: ApiJob): UserConfig => ({
suffix: '.gen',
},
path: job.outputPath,
postProcess: [
{
args: ['fmt', '{{path}}'],
command: 'vp',
},
{
args: ['--fix', '{{path}}/*.ts'],
command: 'eslint',
},
],
},
plugins: [
{

View File

@ -14,8 +14,7 @@
}
},
"scripts": {
"gen-api-contract": "pnpm gen-api-openapi && pnpm gen-api-contract-from-openapi",
"gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
"gen-api-contract": "pnpm gen-api-openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts",
"gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"type-check": "tsgo"

View File

@ -25,14 +25,6 @@
"types": "./src/button/index.tsx",
"import": "./src/button/index.tsx"
},
"./checkbox": {
"types": "./src/checkbox/index.tsx",
"import": "./src/checkbox/index.tsx"
},
"./checkbox-group": {
"types": "./src/checkbox-group/index.tsx",
"import": "./src/checkbox-group/index.tsx"
},
"./combobox": {
"types": "./src/combobox/index.tsx",
"import": "./src/combobox/index.tsx"

View File

@ -1,76 +0,0 @@
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { Checkbox } from '../../checkbox'
import { CheckboxGroup } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('CheckboxGroup', () => {
it('should manage selected values and parent mixed state', async () => {
function PermissionsDemo() {
const [value, setValue] = useState(['read'])
return (
<CheckboxGroup value={value} onValueChange={setValue} allValues={['read', 'write']}>
<Checkbox parent aria-label="All permissions" />
<label>
<Checkbox value="read" />
Read
</label>
<label>
<Checkbox value="write" />
Write
</label>
</CheckboxGroup>
)
}
const screen = await render(<PermissionsDemo />)
const parent = screen.getByRole('checkbox', { name: 'All permissions' })
const write = screen.getByRole('checkbox', { name: 'Write' })
await expect.element(parent).toHaveAttribute('aria-checked', 'mixed')
await expect.element(parent).toHaveAttribute('data-indeterminate', '')
await expect.element(write).toHaveAttribute('aria-checked', 'false')
asHTMLElement(parent.element()).click()
await vi.waitFor(async () => {
await expect.element(parent).toHaveAttribute('aria-checked', 'true')
await expect.element(write).toHaveAttribute('aria-checked', 'true')
})
})
it('should compose with Base UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Field.Root name="features">
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<Fieldset.Legend>Features</Fieldset.Legend>
<Field.Item>
<Field.Label>
<Checkbox value="search" />
Search
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
<Checkbox value="analytics" />
Analytics
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>,
)
const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
await expect.element(analytics).toHaveAttribute('aria-checked', 'false')
asHTMLElement(analytics.element()).click()
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toEqual(['search', 'analytics'])
})
})

View File

@ -1,116 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useId, useState } from 'react'
import { CheckboxGroup } from '.'
import { Checkbox } from '../checkbox'
const meta = {
title: 'Base/UI/CheckboxGroup',
component: CheckboxGroup,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'CheckboxGroup primitive built on Base UI. It owns multi-checkbox array state, allValues, and parent checkbox semantics. Import from `@langgenius/dify-ui/checkbox-group` and compose with `Checkbox` from `@langgenius/dify-ui/checkbox`.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof CheckboxGroup>
export default meta
type Story = StoryObj<typeof meta>
function DocumentSelectionDemo() {
const documentIds = ['doc-1', 'doc-2', 'doc-3']
const [selected, setSelected] = useState<string[]>(['doc-1'])
const groupLabelId = useId()
return (
<CheckboxGroup
aria-labelledby={groupLabelId}
value={selected}
onValueChange={setSelected}
allValues={documentIds}
className="flex flex-col gap-3"
>
<label id={groupLabelId} className="flex items-center gap-2 system-sm-semibold-uppercase text-text-secondary">
<Checkbox parent />
Current page documents
</label>
<div className="flex flex-col gap-2 pl-6">
{[
{ id: 'doc-1', name: 'onboarding-guide.pdf' },
{ id: 'doc-2', name: 'pricing-faq.md' },
{ id: 'doc-3', name: 'release-notes.txt' },
].map(document => (
<label key={document.id} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={document.id} />
{document.name}
</label>
))}
</div>
</CheckboxGroup>
)
}
export const DocumentSelection: Story = {
render: () => <DocumentSelectionDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify table/list selection patterns such as documents, segments, annotations, and install bundle items: CheckboxGroup owns the selected ID array, allValues defines the current selectable page, and the parent checkbox provides select-all plus mixed state.',
},
},
},
}
function DynamicFormFieldDemo() {
const options = [
{ value: 'markdown', label: 'Markdown' },
{ value: 'pdf', label: 'PDF' },
{ value: 'html', label: 'HTML' },
]
const [selected, setSelected] = useState<string[]>(['markdown'])
return (
<Field.Root name="allowed_file_types" className="flex w-80 flex-col gap-2">
<Field.Description className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array.
</Field.Description>
<Fieldset.Root
render={(
<CheckboxGroup
value={selected}
onValueChange={setSelected}
className="flex flex-col gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
/>
)}
>
<Fieldset.Legend className="system-sm-medium text-text-secondary">
Allowed file types
</Fieldset.Legend>
{options.map(option => (
<Field.Item key={option.value}>
<Field.Label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox value={option.value} />
{option.label}
</Field.Label>
</Field.Item>
))}
</Fieldset.Root>
</Field.Root>
)
}
export const DynamicFormField: Story = {
render: () => <DynamicFormFieldDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify checkbox-list form usage in workflow node forms and base form rendering. Field and Fieldset provide group labeling; CheckboxGroup owns controlled array state.',
},
},
},
}

View File

@ -1,10 +0,0 @@
'use client'
import type { CheckboxGroup as BaseCheckboxGroupNS } from '@base-ui/react/checkbox-group'
import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'
export type CheckboxGroupProps = BaseCheckboxGroupNS.Props
export function CheckboxGroup(props: CheckboxGroupProps) {
return <BaseCheckboxGroup {...props} />
}

View File

@ -1,129 +0,0 @@
import { render } from 'vitest-browser-react'
import {
Checkbox,
CheckboxIndicator,
CheckboxRoot,
CheckboxSkeleton,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Checkbox', () => {
it('should render an unchecked checkbox with Base UI semantics', async () => {
const screen = await render(<Checkbox checked={false} aria-label="Accept terms" />)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'false')
await expect.element(checkbox).toHaveAttribute('data-unchecked', '')
await expect.element(checkbox).not.toHaveAttribute('data-checked')
await expect.element(checkbox).not.toHaveAttribute('data-indeterminate')
})
it('should expose checked data attributes and icon styling hooks', async () => {
const screen = await render(<Checkbox checked aria-label="Accept terms" />)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'true')
await expect.element(checkbox).toHaveAttribute('data-checked', '')
await expect.element(checkbox).toHaveClass('data-checked:bg-components-checkbox-bg')
expect(screen.container.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('should expose mixed state when indeterminate', async () => {
const screen = await render(<Checkbox checked={false} indeterminate aria-label="Select all" />)
const checkbox = screen.getByRole('checkbox', { name: 'Select all' })
await expect.element(checkbox).toHaveAttribute('aria-checked', 'mixed')
await expect.element(checkbox).toHaveAttribute('data-indeterminate', '')
expect(screen.container.querySelector('.i-ri-check-line')).not.toBeInTheDocument()
expect(screen.container.querySelector('span span.rounded-full.bg-current')).toBeInTheDocument()
})
it('should call onCheckedChange with the next checked value', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
asHTMLElement(screen.getByRole('checkbox', { name: 'Accept terms' }).element()).click()
expect(onCheckedChange).toHaveBeenCalledTimes(1)
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
})
it('should stay controlled until the checked prop changes', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
asHTMLElement(checkbox.element()).click()
expect(onCheckedChange.mock.calls[0]?.[0]).toBe(true)
await expect.element(checkbox).toHaveAttribute('aria-checked', 'false')
await screen.rerender(<Checkbox checked aria-label="Accept terms" onCheckedChange={onCheckedChange} />)
await expect.element(screen.getByRole('checkbox', { name: 'Accept terms' })).toHaveAttribute('aria-checked', 'true')
})
it('should ignore interaction when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(
<Checkbox checked={false} disabled aria-label="Accept terms" onCheckedChange={onCheckedChange} />,
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
await expect.element(checkbox).toHaveAttribute('data-disabled', '')
await expect.element(checkbox).toHaveClass('data-disabled:cursor-not-allowed')
asHTMLElement(checkbox.element()).click()
expect(onCheckedChange).not.toHaveBeenCalled()
})
it('should submit checked and unchecked form values through the hidden input', async () => {
const screen = await render(
<form>
<Checkbox
checked
name="terms"
value="accepted"
uncheckedValue="declined"
aria-label="Terms"
/>
<Checkbox
checked={false}
name="newsletter"
value="yes"
uncheckedValue="no"
aria-label="Newsletter"
/>
</form>,
)
const form = screen.container.querySelector('form') as HTMLFormElement
const data = new FormData(form)
expect(data.get('terms')).toBe('accepted')
expect(data.get('newsletter')).toBe('no')
})
it('should support custom compound composition with CheckboxRoot and CheckboxIndicator', async () => {
const screen = await render(
<CheckboxRoot checked aria-label="Custom checkbox" className="custom-root">
<CheckboxIndicator className="custom-indicator" />
</CheckboxRoot>,
)
await expect.element(screen.getByRole('checkbox', { name: 'Custom checkbox' })).toHaveClass('custom-root')
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
})
})
describe('CheckboxSkeleton', () => {
it('should render a visual placeholder without checkbox semantics', async () => {
const screen = await render(<CheckboxSkeleton data-testid="checkbox-skeleton" />)
expect(screen.container.querySelector('[role="checkbox"]')).not.toBeInTheDocument()
await expect.element(screen.getByTestId('checkbox-skeleton')).toHaveClass('bg-text-quaternary', 'opacity-20')
})
})

View File

@ -1,145 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Checkbox,
CheckboxSkeleton,
} from '.'
const meta = {
title: 'Base/UI/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Checkbox primitive built on Base UI. It preserves Base UI checked, indeterminate, disabled, and hidden input semantics while applying the Dify 16px checkbox design from Figma. Import from `@langgenius/dify-ui/checkbox`.',
},
},
},
tags: ['autodocs'],
args: {
checked: false,
disabled: false,
indeterminate: false,
},
argTypes: {
checked: {
control: 'boolean',
description: 'Controlled checked state.',
},
indeterminate: {
control: 'boolean',
description: 'Mixed state used by parent or select-all checkboxes.',
},
disabled: {
control: 'boolean',
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
},
},
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
function CheckboxDemo(args: Partial<ComponentProps<typeof Checkbox>>) {
const [checked, setChecked] = useState(args.checked ?? false)
return (
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
{...args}
checked={checked}
onCheckedChange={setChecked}
/>
Enable feature
</label>
)
}
export const Default: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
indeterminate: false,
disabled: false,
},
}
export const Checked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
indeterminate: false,
disabled: false,
},
}
export const Indeterminate: Story = {
args: {
'checked': false,
'indeterminate': true,
'disabled': false,
'aria-label': 'Partial selection',
},
}
export const Disabled: Story = {
render: () => (
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} disabled />
Disabled unchecked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked disabled />
Disabled checked
</label>
<label className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox checked={false} indeterminate disabled />
Disabled mixed
</label>
</div>
),
}
function StateMatrixDemo() {
const states = [
{ label: 'Unchecked', checked: false },
{ label: 'Checked', checked: true },
{ label: 'Indeterminate', checked: false, indeterminate: true },
{ label: 'Disabled unchecked', checked: false, disabled: true },
{ label: 'Disabled checked', checked: true, disabled: true },
{ label: 'Disabled indeterminate', checked: false, indeterminate: true, disabled: true },
]
return (
<div className="flex flex-col gap-3">
{states.map(state => (
<label key={state.label} className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
disabled={state.disabled}
/>
{state.label}
</label>
))}
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
<CheckboxSkeleton aria-hidden="true" />
Skeleton
</div>
</div>
)
}
export const StateMatrix: Story = {
render: () => <StateMatrixDemo />,
parameters: {
docs: {
description: {
story: 'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.',
},
},
},
}

View File

@ -1,100 +0,0 @@
'use client'
import type { Checkbox as BaseCheckboxNS } from '@base-ui/react/checkbox'
import type { HTMLAttributes } from 'react'
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
import { cn } from '../cn'
const checkboxRootClassName = cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3 transition-colors motion-reduce:transition-none',
'border border-components-checkbox-border bg-components-checkbox-bg-unchecked text-components-checkbox-icon',
'hover:border-components-checkbox-border-hover hover:bg-components-checkbox-bg-unchecked-hover',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-checkbox-bg focus-visible:ring-offset-0',
'data-checked:border-transparent data-checked:bg-components-checkbox-bg data-checked:hover:bg-components-checkbox-bg-hover',
'data-indeterminate:border-transparent data-indeterminate:bg-components-checkbox-bg data-indeterminate:hover:bg-components-checkbox-bg-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-checkbox-border-disabled data-disabled:bg-components-checkbox-bg-disabled',
'data-disabled:hover:border-components-checkbox-border-disabled data-disabled:hover:bg-components-checkbox-bg-disabled',
'data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-components-checkbox-bg-disabled-checked data-disabled:data-checked:text-components-checkbox-icon-disabled',
'data-disabled:data-checked:hover:bg-components-checkbox-bg-disabled-checked',
'data-disabled:data-indeterminate:border-transparent data-disabled:data-indeterminate:bg-components-checkbox-bg-disabled-checked data-disabled:data-indeterminate:text-components-checkbox-icon-disabled',
'data-disabled:data-indeterminate:hover:bg-components-checkbox-bg-disabled-checked',
)
const checkboxIndicatorClassName = 'flex size-3 items-center justify-center text-current data-unchecked:hidden'
const checkboxSkeletonClassName = 'size-4 shrink-0 rounded-sm bg-text-quaternary opacity-20'
export type CheckboxRootProps
= Omit<BaseCheckboxNS.Root.Props, 'className'>
& {
className?: string
}
export function CheckboxRoot({
className,
...props
}: CheckboxRootProps) {
return (
<BaseCheckbox.Root
className={cn(checkboxRootClassName, className)}
{...props}
/>
)
}
export type CheckboxIndicatorProps
= Omit<BaseCheckboxNS.Indicator.Props, 'className' | 'children'>
& {
className?: string
}
export function CheckboxIndicator({
className,
render,
...props
}: CheckboxIndicatorProps) {
return (
<BaseCheckbox.Indicator
className={cn(checkboxIndicatorClassName, className)}
render={render ?? ((indicatorProps, state) => (
<span {...indicatorProps}>
{state.indeterminate
? <span className="block h-[1.5px] w-1.75 rounded-full bg-current" />
: <span className="i-ri-check-line block size-3 shrink-0" />}
</span>
))}
{...props}
/>
)
}
export type CheckboxProps
= Omit<CheckboxRootProps, 'children'>
export function Checkbox({
...props
}: CheckboxProps) {
return (
<CheckboxRoot {...props}>
<CheckboxIndicator />
</CheckboxRoot>
)
}
export type CheckboxSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& {
className?: string
}
export function CheckboxSkeleton({
className,
...props
}: CheckboxSkeletonProps) {
return (
<div
className={cn(checkboxSkeletonClassName, className)}
{...props}
/>
)
}

2
pnpm-lock.yaml generated
View File

@ -16367,4 +16367,4 @@ time:
vitest-canvas-mock@1.1.4: '2026-03-24T14:42:39.285Z'
zod@4.4.3: '2026-05-04T07:06:40.819Z'
zundo@2.3.0: '2024-11-17T16:35:11.372Z'
zustand@5.0.13: '2026-05-05T00:04:17.510Z'
zustand@5.0.13: '2026-05-05T00:04:17.510Z'

View File

@ -56,7 +56,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,

View File

@ -64,7 +64,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,

View File

@ -117,7 +117,7 @@ vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
}))

View File

@ -11,7 +11,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import InstalledApp from '@/app/components/explore/installed-app'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
@ -19,7 +19,7 @@ vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))

View File

@ -1,16 +0,0 @@
import type { Locale } from '@/i18n-config'
import AppAccessConfigPage from '@/app/components/app/access-config'
export type AccessConfigPageProps = {
params: Promise<{ locale: Locale, appId: string }>
}
const AccessConfig = async (props: AccessConfigPageProps) => {
const params = await props.params
const { appId } = params
return <AppAccessConfigPage appId={appId} />
}
export default AccessConfig

View File

@ -12,8 +12,6 @@ import {
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import * as React from 'react'
@ -31,7 +29,6 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import s from './style.module.css'
type IAppDetailLayoutProps = {
@ -49,7 +46,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isLoadingCurrentWorkspace, currentWorkspace, workspacePermissionKeys } = useAppContext()
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const appInfoActions = useAppInfoActions({ resetKey: appId })
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
@ -65,16 +62,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: NavIcon
}>>([])
const appACLCapabilities = React.useMemo(
() => getAppACLCapabilities(appDetailRes?.permission_keys),
[appDetailRes?.permission_keys],
)
const canAccessMonitor = appACLCapabilities.canMonitor || hasPermission(workspacePermissionKeys, 'app.monitor.access')
const canAccessLog = appACLCapabilities.canMonitor || hasPermission(workspacePermissionKeys, 'app.log.access')
const getNavigationConfig = useCallback((appId: string, mode: AppModeEnum) => {
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(appACLCapabilities.canViewLayout || appACLCapabilities.canEdit
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
@ -89,7 +79,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(canAccessLog
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
@ -100,26 +90,15 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}]
: []
),
...(canAccessMonitor
? [{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
}]
: []),
...(appACLCapabilities.canAccessConfig
? [{
name: 'Access Config',
href: `/app/${appId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
return navConfig
}, [appACLCapabilities, canAccessLog, canAccessMonitor, t])
}, [t])
useDocumentTitle(appDetail?.name || t('menus.appDetail', { ns: 'common' }))
@ -152,16 +131,8 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
return
const res = appDetailRes
// redirection
const canAccessLayout = appACLCapabilities.canViewLayout || appACLCapabilities.canEdit
if (!canAccessLayout && (pathname.endsWith('configuration') || pathname.endsWith('workflow'))) {
router.replace(`/app/${appId}/overview`)
return
}
if (!canAccessLog && pathname.endsWith('logs')) {
router.replace(`/app/${appId}/overview`)
return
}
if (!appACLCapabilities.canAccessConfig && pathname.endsWith('access-config')) {
const canIEditApp = isCurrentWorkspaceEditor
if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) {
router.replace(`/app/${appId}/overview`)
return
}
@ -173,9 +144,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigationConfig(appId, res.mode))
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
}
}, [appDetailRes, appACLCapabilities, appId, canAccessLog, currentWorkspace.id, getNavigationConfig, isLoadingAppDetail, isLoadingCurrentWorkspace, pathname, router, setAppDetail])
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
useUnmount(() => {
setAppDetail()

View File

@ -4,30 +4,21 @@ import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangS
import type { TracingStatus } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownDoubleLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import {
AliyunIcon,
ArizeIcon,
DatabricksIcon,
LangfuseIcon,
LangsmithIcon,
MlflowIcon,
OpikIcon,
PhoenixIcon,
TencentIcon,
WeaveIcon,
} from '@/app/components/base/icons/src/public/tracing'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useAppContext } from '@/context/app-context'
import { usePathname } from '@/next/navigation'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import ConfigButton from './config-button'
import TracingIcon from './tracing-icon'
import { TracingProvider } from './type'
@ -39,11 +30,8 @@ const Panel: FC = () => {
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const appPermissionKeys = useAppStore(s => s.appDetail?.permission_keys)
const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appPermissionKeys), [appPermissionKeys])
const canConfigTracing = appACLCapabilities.canMonitor || hasPermission(workspacePermissionKeys, 'app.monitor.tracking_config')
const readOnly = !canConfigTracing
const { isCurrentWorkspaceEditor } = useAppContext()
const readOnly = !isCurrentWorkspaceEditor
const [isLoaded, {
setTrue: setLoaded,
@ -265,11 +253,11 @@ const Panel: FC = () => {
<TracingIcon size="md" />
<div className="mx-2 system-sm-semibold text-text-secondary">{t(`${I18N_PREFIX}.title`, { ns: 'app' })}</div>
<div className="rounded-md p-1">
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
<div className="rounded-md p-1">
<span className="i-ri-arrow-down-double-line h-4 w-4 text-text-tertiary" />
<RiArrowDownDoubleLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</ConfigButton>
@ -309,7 +297,7 @@ const Panel: FC = () => {
</div>
{InUseProviderIcon && <InUseProviderIcon className="ml-1 h-4" />}
<div className="ml-2 rounded-md p-1">
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
</div>

View File

@ -1,15 +0,0 @@
import DatasetAccessConfigPage from '@/app/components/datasets/access-config'
type Props = {
params: Promise<{ datasetId: string }>
}
const AccessConfig = async (props: Props) => {
const params = await props.params
const { datasetId } = params
return <DatasetAccessConfigPage datasetId={datasetId} />
}
export default AccessConfig

View File

@ -9,8 +9,6 @@ import {
RiFileTextLine,
RiFocus2Fill,
RiFocus2Line,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -27,7 +25,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { getDatasetACLCapabilities } from '@/utils/permission'
type IAppDetailLayoutProps = {
children: React.ReactNode
@ -72,10 +69,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const shouldRedirect = shouldRedirectToDatasetList(error)
const datasetACLCapabilities = useMemo(
() => getDatasetACLCapabilities(datasetRes?.permission_keys),
[datasetRes?.permission_keys],
)
const { data: relatedApps } = useDatasetRelatedApps(datasetId, { enabled: !!datasetRes && !shouldRedirect })
@ -97,7 +90,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiFocus2Line,
selectedIcon: RiFocus2Fill,
disabled: isButtonDisabledWithPipeline,
visible: datasetACLCapabilities.canRetrievalRecall,
},
{
name: t('datasetMenus.settings', { ns: 'common' }),
@ -105,15 +97,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiEqualizer2Line,
selectedIcon: RiEqualizer2Fill,
disabled: false,
visible: datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit,
},
{
name: 'Access Config',
href: `/datasets/${datasetId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
disabled: false,
visible: datasetACLCapabilities.canAccessConfig,
},
]
@ -124,7 +107,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
visible: datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
@ -132,24 +114,11 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
visible: datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit || datasetACLCapabilities.canUse,
})
}
return baseNavigation.filter(item => item.visible).map(({ visible, ...item }) => item)
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider, datasetACLCapabilities])
const fallbackPath = useMemo(() => {
if (datasetRes?.provider !== 'external' && (datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit || datasetACLCapabilities.canUse))
return `/datasets/${datasetId}/documents`
if (datasetACLCapabilities.canRetrievalRecall)
return `/datasets/${datasetId}/hitTesting`
if (datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit)
return `/datasets/${datasetId}/settings`
if (datasetACLCapabilities.canAccessConfig)
return `/datasets/${datasetId}/access-config`
return '/datasets'
}, [datasetACLCapabilities, datasetId, datasetRes?.provider])
return baseNavigation
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' }))
@ -166,30 +135,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
router.replace('/datasets')
}, [router, shouldRedirect])
useEffect(() => {
if (!datasetRes || shouldRedirect)
return
if (!datasetACLCapabilities.canRetrievalRecall && pathname.endsWith('/hitTesting')) {
router.replace(fallbackPath)
return
}
if (!(datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit) && pathname.endsWith('/settings')) {
router.replace(fallbackPath)
return
}
if (!(datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit) && pathname.endsWith('/pipeline')) {
router.replace(fallbackPath)
return
}
if (!datasetACLCapabilities.canUse && (pathname.endsWith('/documents/create') || pathname.endsWith('/documents/create-from-pipeline'))) {
router.replace(fallbackPath)
return
}
if (!datasetACLCapabilities.canAccessConfig && pathname.endsWith('/access-config'))
router.replace(fallbackPath)
}, [datasetACLCapabilities, datasetRes, fallbackPath, pathname, router, shouldRedirect])
if (!datasetRes && !error)
return <Loading type="app" />

View File

@ -7,7 +7,7 @@ import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { webAppLogout } from '@/service/webapp-auth'

View File

@ -1,125 +0,0 @@
'use client'
import type { AccessPolicyWithBindings, RemoveBindingPayload } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import AccessRuleRow from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import AddRuleTargetsModal from '@/app/components/header/account-setting/access-rules-page/add-rule-targets-modal'
import { useParams } from '@/next/navigation'
import { useUpdateAppAccessRuleBindings } from '@/service/access-control/use-app-access-config'
import { useUpdateDatasetAccessRuleBindings } from '@/service/access-control/use-dataset-access-config'
export type AccessRulesEditorProps = {
rules: AccessPolicyWithBindings[]
className?: string
}
const AccessRulesEditor = ({
rules,
className,
}: AccessRulesEditorProps) => {
const { appId } = useParams() as { appId: string }
const [currentRule, setCurrentRule] = useState<AccessPolicyWithBindings | null>(null)
const handleAddRole = useCallback((rule: AccessPolicyWithBindings) => {
setCurrentRule(rule)
}, [])
const handleCloseAddModal = useCallback(() => {
setCurrentRule(null)
}, [])
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
const handleAddSubmit = useCallback(
(selection: { roleIds: string[], memberIds: string[] }) => {
const { policy } = currentRule || {}
const { id: policyId, resource_type } = policy || {}
if (resource_type === 'app') {
updateAppAccessRuleBindings({
appId,
policyId: policyId || '',
role_ids: selection.roleIds,
account_ids: selection.memberIds,
}, {
onSuccess: () => {
toast.success('Rule binding updated successfully')
},
})
}
else if (resource_type === 'dataset') {
updateDatasetAccessRuleBindings({
datasetId: appId,
policyId: policyId || '',
role_ids: selection.roleIds,
account_ids: selection.memberIds,
}, {
onSuccess: () => {
toast.success('Rule binding updated successfully')
},
})
}
},
[appId, currentRule, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings],
)
const handleRemoveRole = useCallback(
(payload: RemoveBindingPayload) => {
const { policy_id, role_ids, account_ids, resource_type } = payload
if (resource_type === 'app') {
updateAppAccessRuleBindings({
appId,
policyId: policy_id,
role_ids,
account_ids,
}, {
onSuccess: () => {
toast.success('Rule binding removed successfully')
},
})
}
else if (resource_type === 'dataset') {
updateDatasetAccessRuleBindings({
datasetId: appId,
policyId: policy_id,
role_ids,
account_ids,
}, {
onSuccess: () => {
toast.success('Rule binding removed successfully')
},
})
}
},
[appId, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings],
)
return (
<div className={cn('flex flex-col', className)}>
{rules.map((rule, index) => (
<AccessRuleRow
key={rule.policy.id}
rule={rule}
showMenu={false}
onAddRole={handleAddRole}
onRemove={handleRemoveRole}
className={cn(index > 0 && 'border-t border-divider-subtle')}
/>
))}
{currentRule && (
<AddRuleTargetsModal
ruleName={currentRule.policy.name}
initialRoleIds={currentRule.roles.map(role => role.role_id)}
initialMemberIds={currentRule.accounts.map(account => account.account_id)}
onClose={handleCloseAddModal}
onSubmit={handleAddSubmit}
/>
)}
</div>
)
}
export default AccessRulesEditor

View File

@ -15,7 +15,6 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
import AppIcon from '../../base/app-icon'
import { AppInfoDetailDrawer } from './app-info-detail-drawer'
import { getAppModeLabel } from './app-mode-labels'
@ -37,64 +36,53 @@ const AppInfoDetailPanel = ({
exportCheck,
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail.permission_keys), [appDetail.permission_keys])
const primaryOperations = useMemo<Operation[]>(() => [
...(appACLCapabilities.canEdit
? [{
id: 'edit',
title: t('editApp', { ns: 'app' }),
icon: <RiEditLine />,
onClick: () => openModal('edit'),
}]
: []),
{
id: 'edit',
title: t('editApp', { ns: 'app' }),
icon: <RiEditLine />,
onClick: () => openModal('edit'),
},
{
id: 'duplicate',
title: t('duplicate', { ns: 'app' }),
icon: <RiFileCopy2Line />,
onClick: () => openModal('duplicate'),
},
...(appACLCapabilities.canImportExportDSL
? [{
id: 'export',
title: t('export', { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
}]
: []),
], [appACLCapabilities, t, openModal, exportCheck])
{
id: 'export',
title: t('export', { ns: 'app' }),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
},
], [t, openModal, exportCheck])
const secondaryOperations = useMemo<Operation[]>(() => [
...(appACLCapabilities.canImportExportDSL && (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW)
? [{
id: 'import',
title: t('common.importDSL', { ns: 'workflow' }),
icon: <RiFileUploadLine />,
onClick: () => openModal('importDSL'),
}]
: []),
...(appACLCapabilities.canDelete
? [
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => {},
type: 'divider' as const,
},
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
icon: <RiDeleteBinLine />,
onClick: () => openModal('delete'),
},
]
: []),
], [appACLCapabilities, appDetail.mode, t, openModal])
: [],
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => {},
type: 'divider' as const,
},
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
icon: <RiDeleteBinLine />,
onClick: () => openModal('delete'),
},
], [appDetail.mode, t, openModal])
const switchOperation = useMemo(() => {
if (!appACLCapabilities.canEdit)
return null
if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT)
return null
return {
@ -103,7 +91,7 @@ const AppInfoDetailPanel = ({
icon: <RiExchange2Line />,
onClick: () => openModal('switch'),
}
}, [appACLCapabilities.canEdit, appDetail.mode, t, openModal])
}, [appDetail.mode, t, openModal])
return (
<AppInfoDetailDrawer

View File

@ -1,6 +1,6 @@
import type { AppInfoActions } from './use-app-info-actions'
import * as React from 'react'
import { getAppACLCapabilities } from '@/utils/permission'
import { useAppContext } from '@/context/app-context'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
@ -79,12 +79,12 @@ export const AppInfoView = ({
actions,
renderDetail = true,
}: AppInfoViewProps) => {
const { isCurrentWorkspaceEditor } = useAppContext()
const {
appDetail,
panelOpen,
setPanelOpen,
} = actions
const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys)
if (!appDetail)
return null
@ -96,7 +96,7 @@ export const AppInfoView = ({
appDetail={appDetail}
expand={expand}
onClick={() => {
if (appACLCapabilities.canViewLayout || appACLCapabilities.canEdit)
if (isCurrentWorkspaceEditor)
setPanelOpen(v => !v)
}}
/>

View File

@ -26,7 +26,6 @@ import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/kn
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
import { getDatasetACLCapabilities } from '@/utils/permission'
import ActionButton from '../../base/action-button'
import RenameDatasetModal from '../../datasets/rename-modal'
import Menu from './menu'
@ -66,10 +65,6 @@ const DropDown = ({
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys), [dataset?.permission_keys])
const canShowOperations = datasetACLCapabilities.canEdit
|| datasetACLCapabilities.canImportExportDSL
|| datasetACLCapabilities.canDelete
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
@ -130,9 +125,6 @@ const DropDown = ({
}
}, [dataset.id, replace, invalidDatasetList, t])
if (!canShowOperations)
return null
return (
<DropdownMenu
open={open}
@ -154,9 +146,7 @@ const DropDown = ({
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<Menu
showEdit={datasetACLCapabilities.canEdit}
showDelete={!isCurrentWorkspaceDatasetOperator && datasetACLCapabilities.canDelete}
showExportPipeline={datasetACLCapabilities.canImportExportDSL}
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}

View File

@ -6,18 +6,14 @@ import Divider from '../../base/divider'
import MenuItem from './menu-item'
type MenuProps = {
showEdit?: boolean
showDelete: boolean
showExportPipeline?: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Menu = ({
showEdit = true,
showDelete,
showExportPipeline = true,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
@ -28,14 +24,12 @@ const Menu = ({
return (
<div className="flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div className="flex flex-col p-1">
{showEdit && (
<MenuItem
Icon={RiEditLine}
name={t('operation.edit', { ns: 'common' })}
handleClick={openRenameModal}
/>
)}
{showExportPipeline && runtimeMode === 'rag_pipeline' && (
<MenuItem
Icon={RiEditLine}
name={t('operation.edit', { ns: 'common' })}
handleClick={openRenameModal}
/>
{runtimeMode === 'rag_pipeline' && (
<MenuItem
Icon={RiFileDownloadLine}
name={t('operations.exportPipeline', { ns: 'datasetPipeline' })}

View File

@ -1,31 +0,0 @@
'use client'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
import { useAppAccessRules } from '@/service/access-control/use-app-access-config'
type AppAccessConfigPageProps = {
appId: string
}
const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => {
const { data: appAccessRulesResponse } = useAppAccessRules(appId)
const appAccessRules = appAccessRulesResponse?.items || []
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="w-full px-16 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={appAccessRules} />
</div>
</div>
</ScrollArea>
)
}
export default AppAccessConfigPage

View File

@ -36,7 +36,7 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),

View File

@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
}))

View File

@ -26,7 +26,7 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
useUpdateAccessMode: () => mockUseUpdateAccessMode(),

View File

@ -6,7 +6,7 @@ import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
}))

View File

@ -22,7 +22,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control/use-app-access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Loading from '../../base/loading'

View File

@ -4,11 +4,12 @@ import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control/use-app-access-control'
import { useUpdateAccessMode } from '@/service/access-control'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'
@ -78,7 +79,7 @@ export default function AccessControl(props: AccessControlProps) {
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line h-4 w-4 text-text-primary" />
<RiBuildingLine className="h-4 w-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
</div>
</div>
@ -89,7 +90,7 @@ export default function AccessControl(props: AccessControlProps) {
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-verified-badge-line h-4 w-4 text-text-primary" />
<RiVerifiedBadgeLine className="h-4 w-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
@ -97,7 +98,7 @@ export default function AccessControl(props: AccessControlProps) {
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line h-4 w-4 text-text-primary" />
<RiGlobalLine className="h-4 w-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
</div>
</AccessControlItem>

View File

@ -5,7 +5,7 @@ import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/r
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import Loading from '../../base/loading'

View File

@ -66,7 +66,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,

View File

@ -38,7 +38,7 @@ import { appDefaultIconBackground } from '@/config'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'

View File

@ -63,7 +63,7 @@ vi.mock('@/service/use-workflow', () => ({
}),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useAppWhiteListSubjects: () => ({
data: mockAccessSubjects,
}),

View File

@ -17,7 +17,7 @@ import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useAppWorkflow } from '@/service/use-workflow'

View File

@ -101,7 +101,7 @@ vi.mock('@/service/explore', () => ({
fetchInstalledAppList: vi.fn(() => Promise.resolve({ installed_apps: [{ id: 'installed-1' }] })),
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,

View File

@ -37,13 +37,14 @@ import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -52,7 +53,6 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
import { getAppACLCapabilities } from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var'
@ -106,8 +106,6 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const { push } = useRouter()
const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys), [app.permission_keys])
const handleMenuAction = useCallback((e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
@ -136,30 +134,19 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
}
}, [app.id, openAsyncWindow])
const handleOpenAccessConfig = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation()
push(`/app/${app.id}/access-config`)
}, [app.id, push])
return (
<>
{appACLCapabilities.canEdit && (
<>
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onEdit)}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onEdit)}>
<span className="system-sm-regular text-text-secondary">{t('editApp', { ns: 'app' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onDuplicate)}>
<span className="system-sm-regular text-text-secondary">{t('duplicate', { ns: 'app' })}</span>
</DropdownMenuItem>
{appACLCapabilities.canImportExportDSL && (
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onExport)}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</DropdownMenuItem>
)}
{appACLCapabilities.canEdit && shouldShowSwitchOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onExport)}>
<span className="system-sm-regular text-text-secondary">{t('export', { ns: 'app' })}</span>
</DropdownMenuItem>
{shouldShowSwitchOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onSwitch)}>
@ -167,7 +154,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
</DropdownMenuItem>
</>
)}
{appACLCapabilities.canTestAndRun && shouldShowOpenInExploreOption && (
{shouldShowOpenInExploreOption && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
@ -184,25 +171,15 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
<DropdownMenuSeparator />
</>
)}
{appACLCapabilities.canAccessConfig && (
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenAccessConfig}>
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
</DropdownMenuItem>
)}
{appACLCapabilities.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={e => handleMenuAction(e, onDelete)}
>
<span className="system-sm-regular">
{t('operation.delete', { ns: 'common' })}
</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={e => handleMenuAction(e, onDelete)}
>
<span className="system-sm-regular">
{t('operation.delete', { ns: 'common' })}
</span>
</DropdownMenuItem>
</>
)
}
@ -234,13 +211,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
const { t } = useTranslation()
const deleteAppNameInputId = useId()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const appACLCapabilities = useMemo(() => getAppACLCapabilities(app.permission_keys), [app.permission_keys])
const canOpenAppLayout = appACLCapabilities.canViewLayout || appACLCapabilities.canEdit
const canShowOperations = appACLCapabilities.canEdit
|| appACLCapabilities.canImportExportDSL
|| appACLCapabilities.canDelete
|| appACLCapabilities.canAccessConfig
|| appACLCapabilities.canTestAndRun
const { isCurrentWorkspaceEditor } = useAppContext()
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
@ -368,7 +339,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
if (onRefresh)
onRefresh()
onPlanInfoChanged()
getRedirection(true, newApp, push)
getRedirection(isCurrentWorkspaceEditor, newApp, push)
}
catch {
toast.error(t('newApp.appCreateFailed', { ns: 'app' }))
@ -422,7 +393,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
}, [onRefresh, setShowAccessControl])
const shouldShowSwitchOption = app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && appACLCapabilities.canAccessConfig
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
const EditTimeText = useMemo(() => {
@ -452,11 +423,11 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<div
onClick={(e) => {
e.preventDefault()
getRedirection(canOpenAppLayout, app, push)
getRedirection(isCurrentWorkspaceEditor, app, push)
}}
className="group relative col-span-1 inline-flex h-40 cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="relative shrink-0">
<AppIcon
size="large"
@ -471,7 +442,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
<div className="truncate" title={app.name}>{app.name}</div>
</div>
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
<div className="flex items-center gap-1 text-[10px] leading-[18px] font-medium text-text-tertiary">
<div className="truncate" title={app.author_name}>{app.author_name}</div>
<div>·</div>
<div className="truncate" title={EditTimeText}>{EditTimeText}</div>
@ -521,7 +492,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
</div>
</div>
</div>
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div
className="line-clamp-2"
title={app.description}
@ -529,86 +500,88 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
{app.description}
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className={cn('flex w-0 grow items-center gap-1')}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
</div>
{canShowOperations && (
<div
className={cn(
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
{isCurrentWorkspaceEditor && (
<>
<div
className={cn('flex w-0 grow items-center gap-1')}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-[41px] min-w-0 grow overflow-hidden">
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
</div>
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</div>

View File

@ -18,7 +18,6 @@ import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import { hasPermission } from '@/utils/permission'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
@ -44,9 +43,7 @@ const List: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isLoadingCurrentWorkspace, workspacePermissionKeys } = useAppContext()
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create')
const canAccessAppList = hasPermission(workspacePermissionKeys, 'app_library.access')
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
@ -71,7 +68,7 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: canCreateApp,
enabled: isCurrentWorkspaceEditor,
})
const appListQuery = useMemo<AppListQuery>(() => ({
@ -104,7 +101,7 @@ const List: FC<Props> = ({
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: canAccessAppList,
enabled: !isCurrentWorkspaceDatasetOperator,
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
@ -116,12 +113,12 @@ const List: FC<Props> = ({
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line size-3.5" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line size-3.5" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line size-3.5" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line size-3.5" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line size-3.5" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line size-3.5" /> },
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
]
useEffect(() => {
@ -132,7 +129,7 @@ const List: FC<Props> = ({
}, [refetch])
useEffect(() => {
if (!canAccessAppList)
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
@ -159,7 +156,7 @@ const List: FC<Props> = ({
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, canAccessAppList])
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const handleCreatedByMeChange = useCallback(() => {
setIsCreatedByMe(!isCreatedByMe)
@ -228,7 +225,7 @@ const List: FC<Props> = ({
!hasAnyApp && 'overflow-hidden',
)}
>
{(canCreateApp || isLoadingCurrentWorkspace) && (
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
@ -255,7 +252,7 @@ const List: FC<Props> = ({
)}
</div>
{canCreateApp && (
{isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
role="region"

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import AppListContext from '@/context/app-list-context'
import { useProviderContext } from '@/context/provider-context'
import dynamic from '@/next/dynamic'
@ -59,6 +60,7 @@ const CreateAppCard = ({
const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel)
useEffect(() => {
if (controlHideCreateFromTemplatePanel > 0)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setShowNewAppTemplateDialog(false)
}, [controlHideCreateFromTemplatePanel])
@ -66,27 +68,27 @@ const CreateAppCard = ({
<div
ref={ref}
className={cn(
'relative col-span-1 inline-flex h-40 flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
'relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
isLoading && 'pointer-events-none opacity-50',
className,
)}
>
<div className="grow rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
<button type="button" className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppModal(true)}>
<span className="mr-2 i-custom-vender-line-files-file-plus-01 h-4 w-4 shrink-0" />
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
<button type="button" className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppModal(true)}>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</button>
<button type="button" className="flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppTemplateDialog(true)}>
<span className="mr-2 i-custom-vender-line-files-file-plus-02 h-4 w-4 shrink-0" />
<button type="button" className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppTemplateDialog(true)}>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromTemplate', { ns: 'app' })}
</button>
<button
type="button"
onClick={() => setShowCreateFromDSLModal(true)}
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="mr-2 i-custom-vender-line-files-file-arrow-01 h-4 w-4 shrink-0" />
<FileArrow01 className="mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
</div>

View File

@ -1,9 +1,8 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CheckboxList } from '..'
import CheckboxList from '..'
describe('checkbox list component', () => {
const selectAllName = 'common.operation.selectAll'
const options = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
@ -39,7 +38,8 @@ describe('checkbox list component', () => {
it('renders select-all checkbox', () => {
render(<CheckboxList options={options} showSelectAll />)
expect(screen.getByRole('checkbox', { name: selectAllName })).toBeInTheDocument()
const checkboxes = screen.getByTestId('checkbox-selectAll')
expect(checkboxes)!.toBeInTheDocument()
})
it('selects all options when select-all is clicked', async () => {
@ -54,7 +54,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith(['option1', 'option2', 'option3', 'apple'])
@ -73,7 +73,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).not.toHaveBeenCalled()
@ -91,7 +91,7 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
await userEvent.click(selectAll)
expect(onChange).toHaveBeenCalledWith([])
@ -109,14 +109,14 @@ describe('checkbox list component', () => {
/>,
)
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
expect(selectAll).toHaveAttribute('aria-checked', 'true')
const selectAll = screen.getByTestId('checkbox-selectAll')
expect(selectAll.querySelector('[data-testid="check-icon-selectAll"]'))!.toBeInTheDocument()
})
it('hides select-all checkbox when searching', async () => {
render(<CheckboxList options={options} />)
await userEvent.type(screen.getByRole('textbox'), 'app')
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
})
it('selects options when checkbox is clicked', async () => {
@ -131,7 +131,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith(['option1'])
})
@ -148,7 +148,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).toHaveBeenCalledWith([])
})
@ -165,7 +165,7 @@ describe('checkbox list component', () => {
/>,
)
const selectOption = screen.getByRole('checkbox', { name: 'Option 1' })
const selectOption = screen.getByTestId('checkbox-option1')
await userEvent.click(selectOption)
expect(onChange).not.toHaveBeenCalled()
})
@ -202,12 +202,12 @@ describe('checkbox list component', () => {
/>,
)
const disabledCheckbox = screen.getByRole('checkbox', { name: 'Disabled' })
const disabledCheckbox = screen.getByTestId('checkbox-disabled')
await userEvent.click(disabledCheckbox)
expect(onChange).not.toHaveBeenCalled()
})
it('does not toggle option when component is disabled and option label is clicked', async () => {
it('does not toggle option when component is disabled and option is clicked via div', async () => {
const onChange = vi.fn()
render(
@ -219,7 +219,11 @@ describe('checkbox list component', () => {
/>,
)
await userEvent.click(screen.getByText('Option 1'))
// Find option and click the div container
const optionLabels = screen.getAllByText('Option 1')
const optionDiv = optionLabels[0]!.closest('[data-testid="option-item"]')
expect(optionDiv)!.toBeInTheDocument()
await userEvent.click(optionDiv as HTMLElement)
expect(onChange).not.toHaveBeenCalled()
})
@ -242,7 +246,7 @@ describe('checkbox list component', () => {
showSearch={false}
/>,
)
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
expect(screen.queryByTestId('checkbox-selectAll')).not.toBeInTheDocument()
options.forEach((option) => {
expect(screen.getByText(option.label))!.toBeInTheDocument()
})
@ -280,7 +284,7 @@ describe('checkbox list component', () => {
/>,
)
// When some but not all options are selected, clicking select-all should select all remaining options
const selectAll = screen.getByRole('checkbox', { name: selectAllName })
const selectAll = screen.getByTestId('checkbox-selectAll')
expect(selectAll)!.toBeInTheDocument()
expect(selectAll)!.toHaveAttribute('aria-checked', 'mixed')
@ -322,7 +326,7 @@ describe('checkbox list component', () => {
)
const optionLabel = screen.getByText('Option 1')
const optionRow = optionLabel.closest('label[data-testid="option-item"]')
const optionRow = optionLabel.closest('div[data-testid="option-item"]')
expect(optionRow)!.toBeInTheDocument()
await userEvent.click(optionRow as HTMLElement)
@ -343,7 +347,7 @@ describe('checkbox list component', () => {
/>,
)
const optionRow = screen.getByText('Option 1').closest('label[data-testid="option-item"]')
const optionRow = screen.getByText('Option 1').closest('div[data-testid="option-item"]')
expect(optionRow)!.toBeInTheDocument()
await userEvent.click(optionRow as HTMLElement)
@ -400,7 +404,7 @@ describe('checkbox list component', () => {
/>,
)
const checkbox = screen.getByRole('checkbox', { name: 'Option' })
const checkbox = screen.getByTestId('checkbox-option')
await userEvent.click(checkbox)
expect(onChange).not.toHaveBeenCalled()
})

View File

@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { cn } from '@langgenius/dify-ui/cn'
import { useId, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Checkbox from '@/app/components/base/checkbox'
import SearchInput from '@/app/components/base/search-input'
import SearchMenu from '@/assets/search-menu.svg'
@ -30,7 +30,7 @@ type CheckboxListProps = {
maxHeight?: string | number
}
export const CheckboxList = ({
const CheckboxList: FC<CheckboxListProps> = ({
title = '',
label,
description,
@ -43,9 +43,8 @@ export const CheckboxList = ({
showCount = true,
showSearch = true,
maxHeight,
}: CheckboxListProps) => {
}) => {
const { t } = useTranslation()
const groupLabelId = useId()
const [searchQuery, setSearchQuery] = useState('')
const filteredOptions = useMemo(() => {
@ -60,15 +59,48 @@ export const CheckboxList = ({
const selectedCount = value.length
const selectableOptionValues = useMemo(
() => options.filter(option => !option.disabled).map(option => option.value),
[options],
)
const isAllSelected = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
return selectableOptions.length > 0 && selectableOptions.every(option => value.includes(option.value))
}, [options, value])
const isIndeterminate = useMemo(() => {
const selectableOptions = options.filter(option => !option.disabled)
const selectedCount = selectableOptions.filter(option => value.includes(option.value)).length
return selectedCount > 0 && selectedCount < selectableOptions.length
}, [options, value])
const handleSelectAll = useCallback(() => {
if (disabled)
return
if (isAllSelected) {
// Deselect all
onChange?.([])
}
else {
// Select all non-disabled options
const allValues = options
.filter(option => !option.disabled)
.map(option => option.value)
onChange?.(allValues)
}
}, [isAllSelected, options, onChange, disabled])
const handleToggleOption = useCallback((optionValue: string) => {
if (disabled)
return
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue]
onChange?.(newValue)
}, [value, onChange, disabled])
return (
<div className={cn('flex w-full flex-col gap-1', containerClassName)}>
{label && (
<div id={groupLabelId} className="system-sm-medium text-text-secondary">
<div className="system-sm-medium text-text-secondary">
{label}
</div>
)}
@ -78,24 +110,17 @@ export const CheckboxList = ({
</div>
)}
<CheckboxGroup
aria-labelledby={label ? groupLabelId : undefined}
value={value}
onValueChange={nextValue => onChange?.(nextValue)}
allValues={selectableOptionValues}
disabled={disabled}
className="rounded-lg border border-components-panel-border bg-components-panel-bg"
>
<div className="rounded-lg border border-components-panel-border bg-components-panel-bg">
{(showSelectAll || title || showSearch) && (
<div className="relative flex items-center gap-2 border-b border-divider-subtle px-3 py-2">
{!searchQuery && showSelectAll && (
<label className={cn('flex shrink-0 items-center', !disabled && 'cursor-pointer')}>
<Checkbox
parent
disabled={disabled}
/>
<span className="sr-only">{t('operation.selectAll', { ns: 'common' })}</span>
</label>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onCheck={handleSelectAll}
disabled={disabled}
id="selectAll"
/>
)}
{!searchQuery
? (
@ -152,30 +177,45 @@ export const CheckboxList = ({
</div>
)
: (
filteredOptions.map(option => (
<label
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
(option.disabled || disabled) && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
value={option.value}
disabled={option.disabled || disabled}
/>
<span
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<div
key={option.value}
data-testid="option-item"
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-state-base-hover',
option.disabled && 'cursor-not-allowed opacity-50',
)}
onClick={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
>
{option.label}
</span>
</label>
))
<Checkbox
checked={selected}
onCheck={() => {
if (!option.disabled && !disabled)
handleToggleOption(option.value)
}}
disabled={option.disabled || disabled}
id={option.value}
/>
<div
className="flex-1 truncate system-sm-medium text-text-secondary"
title={option.label}
>
{option.label}
</div>
</div>
)
})
)}
</div>
</CheckboxGroup>
</div>
</div>
)
}
export default CheckboxList

View File

@ -394,8 +394,8 @@ describe('BaseField', () => {
fireEvent.click(screen.getByText('Feature B'))
})
const checkboxB = screen.getByRole('checkbox', { name: 'Feature B' })
expect(checkboxB).toHaveAttribute('aria-checked', 'true')
const checkboxB = screen.getByTestId('checkbox-b')
expect(checkboxB).toBeChecked()
})
it('should handle dynamic select error state', () => {

View File

@ -18,7 +18,7 @@ import {
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { CheckboxList } from '@/app/components/base/checkbox-list'
import CheckboxList from '@/app/components/base/checkbox-list'
import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'

View File

@ -1,5 +1,8 @@
'use client'
import type { FC } from 'react'
import {
RiArrowRightUpLine,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
@ -46,7 +49,7 @@ const Billing: FC = () => {
</div>
<span className="inline-flex h-8 w-24 items-center justify-center gap-0.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-saas-dify-blue-accessible shadow-[0_1px_2px_rgba(9,9,11,0.05)] backdrop-blur-[5px]">
<span className="system-sm-medium leading-none">{t('viewBillingAction', { ns: 'billing' })}</span>
<span className="i-ri-arrow-right-up-line h-4 w-4" />
<RiArrowRightUpLine className="h-4 w-4" />
</span>
</button>
)}

View File

@ -4,6 +4,7 @@ import { Button } from '@langgenius/dify-ui/button'
import {
RiBook2Line,
RiFileEditLine,
RiGraduationCapLine,
RiGroupLine,
} from '@remixicon/react'
import { useUnmountedRef } from 'ahooks'
@ -112,14 +113,14 @@ const PlanComp: FC<Props> = ({
<div className="flex shrink-0 items-center gap-1">
{enableEducationPlan && (!isEducationAccount || isAboutToExpire) && (
<Button variant="ghost" onClick={handleVerify} disabled={isPending}>
<span className="mr-1 i-ri-graduation-cap-line h-4 w-4" />
<RiGraduationCapLine className="mr-1 h-4 w-4" />
{t('toVerified', { ns: 'education' })}
{isPending && <Loading className="ml-1 animate-spin-slow" />}
</Button>
)}
{enableEducationPlan && isEducationAccount && type === Plan.sandbox && isCurrentWorkspaceManager && (
<Button variant="ghost" onClick={handleEducationDiscount} disabled={isEducationDiscountLoading}>
<span className="mr-1 i-ri-graduation-cap-line h-4 w-4" />
<RiGraduationCapLine className="mr-1 h-4 w-4" />
{t('useEducationDiscount', { ns: 'education' })}
{isEducationDiscountLoading && <Loading className="ml-1 animate-spin-slow" />}
</Button>

View File

@ -1,31 +0,0 @@
'use client'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
import { useDatasetAccessRules } from '@/service/access-control/use-dataset-access-config'
type DatasetAccessConfigPageProps = {
datasetId: string
}
const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) => {
const { data: datasetAccessRulesResponse } = useDatasetAccessRules(datasetId)
const datasetAccessRules = datasetAccessRulesResponse?.items || []
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="px-12 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={datasetAccessRules} />
</div>
</div>
</ScrollArea>
)
}
export default DatasetAccessConfigPage

View File

@ -10,12 +10,10 @@ import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-la
import Operations from '@/app/components/datasets/documents/components/operations'
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
import StatusItem from '@/app/components/datasets/documents/status-item'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import useTimestamp from '@/hooks/use-timestamp'
import { DataSourceType } from '@/models/datasets'
import { useRouter, useSearchParams } from '@/next/navigation'
import { formatNumber } from '@/utils/format'
import { getDatasetACLCapabilities } from '@/utils/permission'
import DocumentSourceIcon from './document-source-icon'
import { renderTdValue } from './utils'
@ -64,8 +62,6 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
const { formatTime } = useTimestamp()
const router = useRouter()
const searchParams = useSearchParams()
const datasetPermissionKeys = useDatasetDetailContextWithSelector(s => s.dataset?.permission_keys)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetPermissionKeys), [datasetPermissionKeys])
const isFile = doc.data_source_type === DataSourceType.FILE
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
@ -120,25 +116,23 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
<SummaryStatus status={doc.summary_index_status} />
</div>
)}
{datasetACLCapabilities.canEdit && (
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip>
<TooltipTrigger
render={(
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
</div>
)}
/>
<TooltipContent>
{t('list.table.rename', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
</div>
)}
<div className="hidden shrink-0 group-hover:ml-auto group-hover:flex">
<Tooltip>
<TooltipTrigger
render={(
<div
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
onClick={handleRenameClick}
>
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
</div>
)}
/>
<TooltipContent>
{t('list.table.rename', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
</div>
</div>
</td>
<td>

View File

@ -28,8 +28,6 @@ type DocumentsHeaderProps = {
datasetId: string
dataSourceType?: DataSourceType
embeddingAvailable: boolean
canManageMetadata?: boolean
canAddDocument?: boolean
isFreePlan: boolean
// Filter & sort
@ -61,8 +59,6 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
datasetId,
dataSourceType,
embeddingAvailable,
canManageMetadata = true,
canAddDocument = true,
isFreePlan,
statusFilterValue,
sortValue,
@ -176,7 +172,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
description={t('embeddingModelNotAvailable', { ns: 'dataset' })}
/>
)}
{embeddingAvailable && canManageMetadata && (
{embeddingAvailable && (
<Button variant="secondary" className="shrink-0" onClick={showEditMetadataModal}>
<RiDraftLine className="mr-1 size-4" />
{t('metadata.metadata', { ns: 'dataset' })}
@ -194,7 +190,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
onIsBuiltInEnabledChange={onBuiltInEnabledChange}
/>
)}
{embeddingAvailable && canAddDocument && (
{embeddingAvailable && (
<Button variant="primary" onClick={onAddDocument} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4 stroke-current" />
{addButtonText}

View File

@ -12,7 +12,6 @@ import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-meta
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
import { getDatasetACLCapabilities } from '@/utils/permission'
import BatchAction from '../detail/completed/common/batch-action'
import s from '../style.module.css'
import { DocumentTableRow, SortHeader } from './document-list/components'
@ -51,7 +50,6 @@ const DocumentList: FC<DocumentListProps> = ({
}) => {
const { t } = useTranslation()
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetConfig?.permission_keys), [datasetConfig?.permission_keys])
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
const isQAMode = chunkingMode === ChunkingMode.qa
@ -188,14 +186,14 @@ const DocumentList: FC<DocumentListProps> = ({
<BatchAction
className="absolute bottom-16 left-0 z-20"
selectedIds={selectedIds}
onArchive={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.archive) : undefined}
onBatchSummary={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.summary) : undefined}
onBatchEnable={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.enable) : undefined}
onBatchDisable={datasetACLCapabilities.canEdit ? handleAction(DocumentActionType.disable) : undefined}
onBatchDownload={datasetACLCapabilities.canDocumentDownload && downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={datasetACLCapabilities.canDeleteFile ? handleAction(DocumentActionType.delete) : undefined}
onEditMetadata={datasetACLCapabilities.canEdit ? showEditModal : undefined}
onBatchReIndex={datasetACLCapabilities.canEdit && hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
onArchive={handleAction(DocumentActionType.archive)}
onBatchSummary={handleAction(DocumentActionType.summary)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
onCancel={clearSelection}
/>
)}

View File

@ -27,13 +27,11 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { IS_CE_EDITION } from '@/config'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DataSourceType, DocumentActionType } from '@/models/datasets'
import { useRouter } from '@/next/navigation'
import { useDocumentArchive, useDocumentDelete, useDocumentDisable, useDocumentDownload, useDocumentEnable, useDocumentPause, useDocumentResume, useDocumentSummary, useDocumentUnArchive, useSyncDocument, useSyncWebsite } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { downloadUrl } from '@/utils/download'
import { getDatasetACLCapabilities } from '@/utils/permission'
import s from '../style.module.css'
import RenameModal from './rename-modal'
@ -73,13 +71,6 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
const { mutateAsync: generateSummary } = useDocumentSummary()
const { mutateAsync: pauseDocument } = useDocumentPause()
const { mutateAsync: resumeDocument } = useDocumentResume()
const datasetPermissionKeys = useDatasetDetailContextWithSelector(s => s.dataset?.permission_keys)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetPermissionKeys), [datasetPermissionKeys])
const canViewDocumentSettings = datasetACLCapabilities.canReadonly || datasetACLCapabilities.canEdit
const canEditDocument = datasetACLCapabilities.canEdit
const canDownloadDocument = datasetACLCapabilities.canDocumentDownload
const canDeleteDocument = datasetACLCapabilities.canDeleteFile
const canShowOperations = canEditDocument || canDownloadDocument || canDeleteDocument
const isListScene = scene === 'list'
const onOperate = useCallback(async (operationName: OperationName) => {
let opApi
@ -213,7 +204,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (<Switch checked={false} onCheckedChange={noop} disabled={true} size="md" />)}
{isListScene && embeddingAvailable && canEditDocument && (
{isListScene && embeddingAvailable && (
<>
{archived
? (
@ -230,126 +221,118 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
)}
{embeddingAvailable && (
<>
{canViewDocumentSettings && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('list.action.settings', { ns: 'datasetDocuments' })}
className={cn('mr-2 cursor-pointer rounded-lg', !isListScene
? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover'
: 'border-none bg-transparent p-0.5 hover:bg-state-base-hover')}
onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}
>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">
{t('list.action.settings', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
)}
{canShowOperations && (
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && '!h-8 !w-8 rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? '!shadow-none hover:!bg-state-base-hover'
: isListScene && '!bg-transparent',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className={cn(s.commonIcon)}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-text" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={cn('w-[200px] py-0', className)}
>
<div className="w-full py-1">
{!archived && (
<>
{canEditDocument && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canDownloadDocument && data_source_type === DataSourceType.FILE && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canEditDocument && ['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('sync')}>
<span aria-hidden className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canEditDocument && IS_CE_EDITION && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('summary')}>
<span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</button>
)}
<Divider className="my-1" />
</>
)}
{canDownloadDocument && archived && data_source_type === DataSourceType.FILE && (
<>
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
aria-label={t('list.action.settings', { ns: 'datasetDocuments' })}
className={cn('mr-2 cursor-pointer rounded-lg', !isListScene
? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover'
: 'border-none bg-transparent p-0.5 hover:bg-state-base-hover')}
onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)}
>
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-components-button-secondary-text" />
</button>
)}
/>
<TooltipContent className="system-xs-medium text-text-secondary">
{t('list.action.settings', { ns: 'datasetDocuments' })}
</TooltipContent>
</Tooltip>
<DropdownMenu open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isListScene ? s.actionIconWrapperList : s.actionIconWrapperDetail,
'inline-flex items-center justify-center',
!isListScene && '!h-8 !w-8 rounded-lg backdrop-blur-[5px]',
isOperationsMenuOpen
? '!shadow-none hover:!bg-state-base-hover'
: isListScene && '!bg-transparent',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className={cn(s.commonIcon)}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-text" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={cn('w-[200px] py-0', className)}
>
<div className="w-full py-1">
{!archived && (
<>
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleShowRename}>
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</button>
{data_source_type === DataSourceType.FILE && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</button>
<Divider className="my-1" />
</>
)}
{canEditDocument && !archived && display_status?.toLowerCase() === 'indexing' && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('pause')}>
<span aria-hidden className="i-ri-pause-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('sync')}>
<span aria-hidden className="i-ri-loop-left-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.sync', { ns: 'datasetDocuments' })}</span>
</button>
)}
{IS_CE_EDITION && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('summary')}>
<span aria-hidden className="i-custom-vender-knowledge-search-lines-sparkle h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.summary', { ns: 'datasetDocuments' })}</span>
</button>
)}
<Divider className="my-1" />
</>
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={handleDownloadClick}>
<span aria-hidden className="i-ri-download-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canEditDocument && !archived && display_status?.toLowerCase() === 'paused' && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('resume')}>
<span aria-hidden className="i-ri-play-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canEditDocument && !archived && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canEditDocument && archived && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('un_archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span>
</button>
)}
{canDeleteDocument && (
<button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('pause')}>
<span aria-hidden className="i-ri-pause-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.pause', { ns: 'datasetDocuments' })}</span>
</button>
)}
{!archived && display_status?.toLowerCase() === 'paused' && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('resume')}>
<span aria-hidden className="i-ri-play-circle-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.resume', { ns: 'datasetDocuments' })}</span>
</button>
)}
{!archived && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.archive', { ns: 'datasetDocuments' })}</span>
</button>
)}
{archived && (
<button type="button" className={cn(menuActionClassName, 'text-left')} onClick={() => handleMenuOperation('un_archive')}>
<span aria-hidden className="i-ri-archive-2-line h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.unarchive', { ns: 'datasetDocuments' })}</span>
</button>
)}
<button type="button" className={cn(menuDeleteActionClassName, 'text-left')} onClick={handleDeleteClick}>
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
<span className={cn(s.actionName, 'group-hover:text-text-destructive')}>{t('list.action.delete', { ns: 'datasetDocuments' })}</span>
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<AlertDialog open={showModal} onOpenChange={open => !open && setShowModal(false)}>

View File

@ -22,10 +22,10 @@ const i18nPrefix = 'batchAction'
type IBatchActionProps = {
className?: string
selectedIds: string[]
onBatchEnable?: () => void
onBatchDisable?: () => void
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete?: () => Promise<void>
onBatchDelete: () => Promise<void>
onBatchSummary?: () => void
onArchive?: () => void
onEditMetadata?: () => void
@ -56,9 +56,6 @@ const BatchAction: FC<IBatchActionProps> = ({
}] = useBoolean(false)
const handleBatchDelete = async () => {
if (!onBatchDelete)
return
setIsDeleting()
await onBatchDelete()
hideDeleteConfirm()
@ -73,26 +70,22 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="system-sm-semibold text-text-accent">{t(`${i18nPrefix}.selected`, { ns: 'dataset' })}</span>
</div>
<Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" />
{onBatchEnable && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchEnable}
>
<RiCheckboxCircleLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.enable`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDisable && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDisable}
>
<RiCloseCircleLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.disable`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchEnable}
>
<RiCheckboxCircleLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.enable`, { ns: 'dataset' })}</span>
</Button>
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDisable}
>
<RiCloseCircleLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.disable`, { ns: 'dataset' })}</span>
</Button>
{onEditMetadata && (
<Button
variant="ghost"
@ -143,17 +136,15 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDelete && (
<Button
variant="ghost"
tone="destructive"
className="gap-x-0.5 px-3"
onClick={showDeleteConfirm}
>
<RiDeleteBinLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.delete`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
tone="destructive"
className="gap-x-0.5 px-3"
onClick={showDeleteConfirm}
>
<RiDeleteBinLine className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.delete`, { ns: 'dataset' })}</span>
</Button>
<Divider type="vertical" className="mx-0.5 h-3.5 bg-divider-regular" />
<Button
@ -164,26 +155,24 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.cancel`, { ns: 'dataset' })}</span>
</Button>
</div>
{onBatchDelete && (
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('list.delete.content', { ns: 'datasetDocuments' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}>
{t('operation.sure', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialog open={isShowDeleteConfirm} onOpenChange={open => !open && hideDeleteConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('list.delete.title', { ns: 'datasetDocuments' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('list.delete.content', { ns: 'datasetDocuments' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} disabled={isDeleting} onClick={handleBatchDelete}>
{t('operation.sure', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -9,7 +9,6 @@ import { useRouter } from '@/next/navigation'
import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { getDatasetACLCapabilities } from '@/utils/permission'
import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata'
import DocumentsHeader from './components/documents-header'
import EmptyElement from './components/empty-element'
@ -31,7 +30,6 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const embeddingAvailable = !!dataset?.embedding_available
const datasetACLCapabilities = getDatasetACLCapabilities(dataset?.permission_keys)
// Use custom hook for page state management
const {
@ -108,14 +106,12 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
// Route to document creation page
const routeToDocCreate = useCallback(() => {
if (!datasetACLCapabilities.canUse)
return
if (dataset?.runtime_mode === 'rag_pipeline') {
router.push(`/datasets/${datasetId}/documents/create-from-pipeline`)
return
}
router.push(`/datasets/${datasetId}/documents/create`)
}, [dataset?.runtime_mode, datasetACLCapabilities.canUse, datasetId, router])
}, [dataset?.runtime_mode, datasetId, router])
const total = documentsRes?.total || 0
const documentsList = documentsRes?.data
@ -151,7 +147,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION
return (
<EmptyElement
canAdd={embeddingAvailable && datasetACLCapabilities.canUse}
canAdd={embeddingAvailable}
onClick={routeToDocCreate}
type={isDataSourceNotion ? 'sync' : 'upload'}
/>
@ -164,8 +160,6 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
datasetId={datasetId}
dataSourceType={dataset?.data_source_type}
embeddingAvailable={embeddingAvailable}
canManageMetadata={datasetACLCapabilities.canEdit}
canAddDocument={datasetACLCapabilities.canUse}
isFreePlan={isFreePlan}
statusFilterValue={statusFilterValue}
sortValue={sortValue}

View File

@ -18,7 +18,6 @@ describe('Operations', () => {
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
openAccessConfig: vi.fn(),
}
beforeEach(() => {
@ -81,14 +80,6 @@ describe('Operations', () => {
fireEvent.click(screen.getByText(/operation\.delete/))
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
it('should call openAccessConfig when access config is clicked', () => {
const openAccessConfig = vi.fn()
renderInMenu(<Operations {...defaultProps} openAccessConfig={openAccessConfig} />)
fireEvent.click(screen.getByText('Access Config'))
expect(openAccessConfig).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {

View File

@ -71,12 +71,10 @@ describe('DatasetCardModals', () => {
modalState: {
showRenameModal: false,
showConfirmDelete: false,
showAccessConfig: false,
confirmMessage: '',
},
onCloseRename: vi.fn(),
onCloseConfirm: vi.fn(),
onCloseAccessConfig: vi.fn(),
onConfirmDelete: vi.fn(),
onSuccess: vi.fn(),
}
@ -211,7 +209,6 @@ describe('DatasetCardModals', () => {
modalState={{
showRenameModal: true,
showConfirmDelete: true,
showAccessConfig: false,
confirmMessage: 'Delete this dataset?',
}}
/>,

View File

@ -34,7 +34,6 @@ describe('OperationsDropdown', () => {
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
openAccessConfig: vi.fn(),
}
beforeEach(() => {

View File

@ -15,7 +15,6 @@ import RenameDatasetModal from '../../../rename-modal'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
showAccessConfig: boolean
confirmMessage: string
}
@ -24,7 +23,6 @@ type DatasetCardModalsProps = {
modalState: ModalState
onCloseRename: () => void
onCloseConfirm: () => void
onCloseAccessConfig: () => void
onConfirmDelete: () => void
onSuccess?: () => void
}

View File

@ -6,7 +6,6 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { getDatasetACLCapabilities } from '@/utils/permission'
import Operations from '../operations'
type OperationsDropdownProps = {
@ -15,7 +14,6 @@ type OperationsDropdownProps = {
openRenameModal: () => void
handleExportPipeline: (include?: boolean) => void
detectIsUsedByApp: () => void
openAccessConfig: () => void
}
const OperationsDropdown = ({
@ -24,17 +22,8 @@ const OperationsDropdown = ({
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
openAccessConfig,
}: OperationsDropdownProps) => {
const [open, setOpen] = React.useState(false)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys), [dataset.permission_keys])
const canShowOperations = datasetACLCapabilities.canEdit
|| datasetACLCapabilities.canImportExportDSL
|| datasetACLCapabilities.canAccessConfig
|| datasetACLCapabilities.canDelete
if (!canShowOperations)
return null
return (
<div
@ -64,14 +53,11 @@ const OperationsDropdown = ({
popupClassName="min-w-[186px]"
>
<Operations
showEdit={datasetACLCapabilities.canEdit}
showDelete={!isCurrentWorkspaceDatasetOperator && datasetACLCapabilities.canDelete}
showExportPipeline={dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL}
showAccessConfig={datasetACLCapabilities.canAccessConfig}
showDelete={!isCurrentWorkspaceDatasetOperator}
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -2,7 +2,6 @@ import type { DataSet } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import { downloadBlob } from '@/utils/download'
@ -10,7 +9,6 @@ import { downloadBlob } from '@/utils/download'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
showAccessConfig: boolean
confirmMessage: string
}
@ -21,13 +19,11 @@ type UseDatasetCardStateOptions = {
export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => {
const { t } = useTranslation()
const { push } = useRouter()
// Modal state
const [modalState, setModalState] = useState<ModalState>({
showRenameModal: false,
showConfirmDelete: false,
showAccessConfig: false,
confirmMessage: '',
})
@ -47,14 +43,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
setModalState(prev => ({ ...prev, showConfirmDelete: false }))
}, [])
const openAccessConfig = useCallback(() => {
push(`/datasets/${dataset.id}/access-config`)
}, [dataset.id, push])
const closeAccessConfig = useCallback(() => {
setModalState(prev => ({ ...prev, showAccessConfig: false }))
}, [])
// API mutations
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
@ -124,8 +112,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
openRenameModal,
closeRenameModal,
closeConfirmDelete,
openAccessConfig,
closeAccessConfig,
// Export state
exporting,

View File

@ -35,8 +35,6 @@ const DatasetCard = ({
openRenameModal,
closeRenameModal,
closeConfirmDelete,
openAccessConfig,
closeAccessConfig,
handleExportPipeline,
detectIsUsedByApp,
onConfirmDelete,
@ -65,7 +63,7 @@ const DatasetCard = ({
return (
<>
<div
className="group relative col-span-1 flex h-47.5 cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
data-disable-nprogress={true}
onClick={handleCardClick}
>
@ -87,7 +85,6 @@ const DatasetCard = ({
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
</div>
<DatasetCardModals
@ -95,7 +92,6 @@ const DatasetCard = ({
modalState={modalState}
onCloseRename={closeRenameModal}
onCloseConfirm={closeConfirmDelete}
onCloseAccessConfig={closeAccessConfig}
onConfirmDelete={onConfirmDelete}
onSuccess={onSuccess}
/>

View File

@ -6,26 +6,20 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
type OperationsProps = {
showEdit?: boolean
showDelete: boolean
showExportPipeline: boolean
showAccessConfig?: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
openAccessConfig: () => void
onClose?: () => void
}
const Operations = ({
showEdit = true,
showDelete,
showExportPipeline,
showAccessConfig = false,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
openAccessConfig,
onClose,
}: OperationsProps) => {
const { t } = useTranslation()
@ -45,36 +39,23 @@ const Operations = ({
detectIsUsedByApp()
}
const handleAccessConfig = () => {
onClose?.()
openAccessConfig()
}
return (
<>
{showEdit && (
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
{showExportPipeline && (
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="mr-1 i-ri-file-download-line size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</DropdownMenuItem>
)}
{showAccessConfig && (
<DropdownMenuItem onClick={handleAccessConfig}>
<span aria-hidden className="mr-1 i-ri-user-settings-line size-4 text-text-tertiary" />
Access Config
</DropdownMenuItem>
)}
{showDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<span aria-hidden className="mr-1 i-ri-delete-bin-line size-4" />
<span aria-hidden className="i-ri-delete-bin-line size-4" />
{t('operation.delete', { ns: 'common' })}
</DropdownMenuItem>
</>

View File

@ -3,6 +3,7 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import DatasetCard from './dataset-card'
import NewDatasetCard from './new-dataset-card'
@ -21,6 +22,7 @@ const Datasets = ({
onOpenTagManagement = () => {},
}: Props) => {
const { t } = useTranslation()
const isCurrentWorkspaceEditor = useAppContextWithSelector(state => state.isCurrentWorkspaceEditor)
const {
data: datasetList,
fetchNextPage,
@ -58,7 +60,7 @@ const Datasets = ({
return (
<>
<nav className="grid grow grid-cols-1 content-start gap-3 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<NewDatasetCard />
{isCurrentWorkspaceEditor && <NewDatasetCard />}
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
))}

View File

@ -16,7 +16,6 @@ import { TagManagementModal } from '@/features/tag-management/components/tag-man
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { hasPermission } from '@/utils/permission'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
@ -53,9 +52,7 @@ const List = () => {
}
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
return (
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
@ -85,18 +82,14 @@ const List = () => {
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
{canConnectExternalDataset && (
<>
<div className="h-4 w-px bg-divider-regular" />
<Button
className="gap-0.5 shadow-xs"
onClick={() => setShowExternalApiPanel(true)}
>
<span className="i-custom-vender-solid-development-api-connection-mod h-4 w-4 text-components-button-secondary-text" />
<span className="flex items-center justify-center gap-1 px-0.5 system-sm-medium text-components-button-secondary-text">{t('externalAPIPanelTitle', { ns: 'dataset' })}</span>
</Button>
</>
)}
<div className="h-4 w-px bg-divider-regular" />
<Button
className="gap-0.5 shadow-xs"
onClick={() => setShowExternalApiPanel(true)}
>
<span className="i-custom-vender-solid-development-api-connection-mod h-4 w-4 text-components-button-secondary-text" />
<span className="flex items-center justify-center gap-1 px-0.5 system-sm-medium text-components-button-secondary-text">{t('externalAPIPanelTitle', { ns: 'dataset' })}</span>
</Button>
</div>
</div>
<Datasets tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} onOpenTagManagement={() => setShowTagManagementModal(true)} />

View File

@ -6,27 +6,20 @@ import {
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { hasPermission } from '@/utils/permission'
import Option from './option'
const CreateAppCard = () => {
const { t } = useTranslation()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canAddDataset = hasPermission(workspacePermissionKeys, 'dataset.create')
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
return (
<div className="flex h-47.5 flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed">
<div className="flex h-[190px] flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed">
<div className="flex grow flex-col items-center justify-center p-2">
<Option
disabled={!canAddDataset}
href="/datasets/create"
Icon={RiAddLine}
text={t('createDataset', { ns: 'dataset' })}
/>
<Option
disabled={!canAddDataset}
href="/datasets/create-from-pipeline"
Icon={RiFunctionAddLine}
text={t('createFromPipeline', { ns: 'dataset' })}
@ -34,7 +27,6 @@ const CreateAppCard = () => {
</div>
<div className="border-t-[0.5px] border-divider-subtle p-2">
<Option
disabled={!canConnectExternalDataset}
href="/datasets/connect"
Icon={ApiConnectionMod}
text={t('connectDataset', { ns: 'dataset' })}

Some files were not shown because too many files have changed in this diff Show More