mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 00:16:37 +08:00
Compare commits
1 Commits
feat/rbac
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a4d978f2 |
6
.github/workflows/autofix.yml
vendored
6
.github/workflows/autofix.yml
vendored
@ -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'
|
||||
|
||||
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@ -9,7 +9,6 @@ on:
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-backend"
|
||||
- "feat/rbac"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
4
.github/workflows/docker-build.yml
vendored
4
.github/workflows/docker-build.yml
vendored
@ -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 }}
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"))
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
)
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
|
||||
@ -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",
|
||||
}
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ export type EmailCodeLoginPayload = {
|
||||
code: string
|
||||
email: string
|
||||
language?: string | null
|
||||
timezone?: string | null
|
||||
token: string
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -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
2
pnpm-lock.yaml
generated
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }),
|
||||
}))
|
||||
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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" />
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
}))
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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?',
|
||||
}}
|
||||
/>,
|
||||
|
||||
@ -34,7 +34,6 @@ describe('OperationsDropdown', () => {
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
openAccessConfig: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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} />),
|
||||
))}
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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
Reference in New Issue
Block a user