mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 16:27:17 +08:00
Compare commits
1 Commits
feat/rbac
...
codex/dev-
| Author | SHA1 | Date | |
|---|---|---|---|
| cf9e649e11 |
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 }}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -31,7 +31,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
|
||||
@ -42,7 +41,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,
|
||||
@ -348,7 +346,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
|
||||
@ -384,7 +381,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
|
||||
@ -417,22 +413,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_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
|
||||
|
||||
@ -519,20 +499,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"}
|
||||
]
|
||||
@ -610,7 +576,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)
|
||||
@ -619,16 +584,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")
|
||||
|
||||
|
||||
@ -58,7 +58,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_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse)
|
||||
|
||||
@ -131,14 +130,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)
|
||||
@ -341,19 +332,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)
|
||||
@ -435,7 +413,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
|
||||
|
||||
|
||||
@ -460,7 +437,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:
|
||||
@ -530,7 +506,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
|
||||
|
||||
|
||||
@ -31,14 +31,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
|
||||
|
||||
|
||||
@ -79,19 +78,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."""
|
||||
@ -105,36 +91,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
|
||||
|
||||
@ -155,9 +112,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
@ -182,7 +182,6 @@ class SystemFeatureModel(FeatureResponseModel):
|
||||
enable_creators_platform: bool = False
|
||||
enable_trial_app: bool = False
|
||||
enable_explore_banner: bool = False
|
||||
rbac_enabled: bool = False
|
||||
|
||||
|
||||
class FeatureService:
|
||||
@ -232,7 +231,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,562 +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 = {
|
||||
"acct-2": [
|
||||
{"id": "role-1", "name": "Admin"},
|
||||
{"id": "role-2", "name": "Editor"},
|
||||
],
|
||||
"acct-3": [],
|
||||
}
|
||||
|
||||
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
|
||||
assert out[1].account_id == "acct-3"
|
||||
assert out[1].roles == []
|
||||
|
||||
|
||||
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
|
||||
@ -848,21 +809,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
|
||||
@ -914,11 +860,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
|
||||
@ -1710,7 +1651,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": {
|
||||
@ -1949,36 +1889,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
|
||||
@ -2050,16 +1970,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
|
||||
@ -2086,11 +1996,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
|
||||
@ -2106,11 +2011,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
|
||||
@ -2220,16 +2120,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
|
||||
@ -2429,14 +2319,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
|
||||
@ -2534,9 +2416,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/install-plugin/install-from-github/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -2553,10 +2432,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
|
||||
}
|
||||
@ -2670,24 +2549,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
|
||||
@ -2716,7 +2577,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
|
||||
}
|
||||
@ -2756,21 +2617,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
|
||||
@ -3017,19 +2863,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
|
||||
@ -3367,11 +3200,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
|
||||
@ -3380,11 +3208,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
|
||||
@ -3426,11 +3249,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
|
||||
@ -3451,11 +3269,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
|
||||
@ -3793,16 +3606,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
|
||||
@ -3843,11 +3646,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
|
||||
@ -3922,11 +3720,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
|
||||
@ -3998,16 +3791,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
|
||||
@ -4052,9 +3835,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4218,11 +3998,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
|
||||
@ -4241,11 +4016,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
|
||||
@ -4274,6 +4044,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": {
|
||||
@ -4666,14 +4439,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
|
||||
|
||||
@ -13,7 +13,7 @@ Add a script in your frontend project:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env"
|
||||
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -36,10 +36,14 @@ Supported options:
|
||||
- `--env-file`: load environment variables before evaluating the config file.
|
||||
- `--host`: override `server.host` from config.
|
||||
- `--port`: override `server.port` from config.
|
||||
- `--watch`: reload config and env file changes. Enabled by default.
|
||||
- `--no-watch`: disable config and env file reloads.
|
||||
- `--help`, `-h`: print help.
|
||||
|
||||
`--target` is not supported. Put targets in the config file so routes and upstreams stay explicit.
|
||||
|
||||
The CLI watches the config file and the explicit `--env-file` by default. Route, CORS, target, and cookie rewrite changes are applied in the running process. If the resolved host or port changes, the proxy closes the old server and starts a new one.
|
||||
|
||||
## Config Shape
|
||||
|
||||
```ts
|
||||
@ -108,9 +112,11 @@ DEV_PROXY_PORT=5001
|
||||
Command:
|
||||
|
||||
```bash
|
||||
dev-proxy --config ./dev-proxy.config.ts --env-file ./.env
|
||||
dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local
|
||||
```
|
||||
|
||||
Edits to `./.env.local` reload the proxy automatically.
|
||||
|
||||
## Scenario 2: Proxy Two Route Groups To Two Local Backends
|
||||
|
||||
Use this when one frontend needs to talk to two different local services. For example:
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "catalog:",
|
||||
"c12": "catalog:",
|
||||
"chokidar": "catalog:",
|
||||
"hono": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import type { Server } from 'node:http'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs/promises'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@ -16,6 +18,7 @@ const tempDirs: string[] = []
|
||||
type DevProxyCliProcess = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
||||
const childProcesses: DevProxyCliProcess[] = []
|
||||
const httpServers: Server[] = []
|
||||
const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url))
|
||||
|
||||
const createTempDir = async () => {
|
||||
@ -86,6 +89,23 @@ const waitForOutput = (
|
||||
onData()
|
||||
})
|
||||
|
||||
const fetchTextWithRetry = async (url: string) => {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
return response.text()
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const spawnCli = (args: readonly string[], cwd: string) => {
|
||||
const child = spawn(process.execPath, [binPath, ...args], {
|
||||
cwd,
|
||||
@ -107,9 +127,45 @@ const stopChildProcess = async (child: DevProxyCliProcess) => {
|
||||
await once(child, 'exit')
|
||||
}
|
||||
|
||||
const closeHttpServer = async (server: Server) => {
|
||||
if (!server.listening)
|
||||
return
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const startTextServer = async (body: string) => {
|
||||
const server = http.createServer((_, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' })
|
||||
response.end(body)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('Failed to start test server.')
|
||||
|
||||
httpServers.push(server)
|
||||
return {
|
||||
port: address.port,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dev proxy CLI', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(childProcesses.splice(0).map(stopChildProcess))
|
||||
await Promise.all(httpServers.splice(0).map(closeHttpServer))
|
||||
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
@ -155,4 +211,49 @@ describe('dev proxy CLI', () => {
|
||||
expect(child.signalCode).toBeNull()
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
// Scenario: editing the configured env file should reload route targets without restarting the CLI process.
|
||||
it('should reload proxy config when the env file changes', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
const port = await getFreePort()
|
||||
const firstTarget = await startTextServer('first target')
|
||||
const secondTarget = await startTextServer('second target')
|
||||
|
||||
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${firstTarget.port}\n`)
|
||||
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
|
||||
export default {
|
||||
routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }],
|
||||
}
|
||||
`)
|
||||
|
||||
let output = ''
|
||||
const child = spawnCli([
|
||||
'--config',
|
||||
'./dev-proxy.config.ts',
|
||||
'--env-file',
|
||||
'./.env.proxy',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port),
|
||||
], tempDir)
|
||||
child.stdout.on('data', chunk => output += chunk.toString())
|
||||
child.stderr.on('data', chunk => output += chunk.toString())
|
||||
const proxyUrl = `http://127.0.0.1:${port}/api/ping`
|
||||
|
||||
// Act
|
||||
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
|
||||
const firstResponse = await fetchTextWithRetry(proxyUrl)
|
||||
|
||||
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${secondTarget.port}\n`)
|
||||
await waitForOutput(child, () => output, '[dev-proxy] reloaded env file changes')
|
||||
const secondResponse = await fetchTextWithRetry(proxyUrl)
|
||||
|
||||
// Assert
|
||||
expect(firstResponse).toBe('first target')
|
||||
expect(secondResponse).toBe('second target')
|
||||
expect(child.exitCode).toBeNull()
|
||||
expect(child.signalCode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import type { ServerType } from '@hono/node-server'
|
||||
import type { DevProxyCliOptions, DevProxyConfig } from './types'
|
||||
import process from 'node:process'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
|
||||
import { watch } from 'chokidar'
|
||||
import { assertDevProxyConfig, loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions, watchDevProxyConfig } from './config'
|
||||
import { createDevProxyApp } from './server'
|
||||
|
||||
function printUsage() {
|
||||
@ -12,6 +15,8 @@ Options:
|
||||
--env-file <path> Load environment variables before evaluating the config file.
|
||||
--host <host> Override the configured host.
|
||||
--port <port> Override the configured port.
|
||||
--watch Reload config and env file changes. Enabled by default.
|
||||
--no-watch Disable config and env file reloads.
|
||||
--help, -h Show this help message.`)
|
||||
}
|
||||
|
||||
@ -22,6 +27,78 @@ async function flushStandardStreams() {
|
||||
])
|
||||
}
|
||||
|
||||
const closeServer = (server: ServerType) => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const startDevProxyServer = (config: DevProxyConfig, cliOptions: DevProxyCliOptions) => {
|
||||
let app = createDevProxyApp(config)
|
||||
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
|
||||
const server = serve({
|
||||
fetch: (request, env) => app.fetch(request, env),
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
server,
|
||||
updateConfig(nextConfig: DevProxyConfig) {
|
||||
app = createDevProxyApp(nextConfig)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createDevProxyRuntime = (initialConfig: DevProxyConfig, cliOptions: DevProxyCliOptions) => {
|
||||
let runtime = startDevProxyServer(initialConfig, cliOptions)
|
||||
let reloadTask = Promise.resolve()
|
||||
|
||||
console.log(`[dev-proxy] listening on http://${runtime.host}:${runtime.port}`)
|
||||
|
||||
const reload = async (nextConfig: unknown, reason: string) => {
|
||||
assertDevProxyConfig(nextConfig)
|
||||
const nextServerOptions = resolveDevProxyServerOptions(nextConfig.server, cliOptions)
|
||||
|
||||
if (runtime.host === nextServerOptions.host && runtime.port === nextServerOptions.port) {
|
||||
runtime.updateConfig(nextConfig)
|
||||
console.log(`[dev-proxy] reloaded ${reason}`)
|
||||
return
|
||||
}
|
||||
|
||||
await closeServer(runtime.server)
|
||||
runtime = startDevProxyServer(nextConfig, cliOptions)
|
||||
console.log(`[dev-proxy] restarted on http://${runtime.host}:${runtime.port} after ${reason}`)
|
||||
}
|
||||
|
||||
const enqueueReload = (loadConfig: () => Promise<unknown> | unknown, reason: string) => {
|
||||
reloadTask = reloadTask.then(async () => {
|
||||
try {
|
||||
await reload(await loadConfig(), reason)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[dev-proxy] failed to reload ${reason}`)
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
}
|
||||
})
|
||||
|
||||
return reloadTask
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueReload,
|
||||
close: async () => {
|
||||
await reloadTask
|
||||
await closeServer(runtime.server)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliOptions = parseDevProxyCliArgs(process.argv.slice(2))
|
||||
|
||||
@ -33,16 +110,44 @@ async function main() {
|
||||
const config = await loadDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
})
|
||||
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
|
||||
const app = createDevProxyApp(config)
|
||||
const runtime = createDevProxyRuntime(config, cliOptions)
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
if (cliOptions.watch === false)
|
||||
return
|
||||
|
||||
const configWatcher = await watchDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
onUpdate: ({ newConfig }) => runtime.enqueueReload(() => newConfig.config, 'config changes'),
|
||||
})
|
||||
|
||||
console.log(`[dev-proxy] listening on http://${host}:${port}`)
|
||||
const envWatcher = cliOptions.envFile
|
||||
? watch(cliOptions.envFile, {
|
||||
cwd: process.cwd(),
|
||||
ignoreInitial: true,
|
||||
})
|
||||
: undefined
|
||||
|
||||
envWatcher?.on('all', () => {
|
||||
void runtime.enqueueReload(
|
||||
() => loadDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
}),
|
||||
'env file changes',
|
||||
)
|
||||
})
|
||||
|
||||
const cleanup = async () => {
|
||||
await envWatcher?.close()
|
||||
await configWatcher.unwatch()
|
||||
await runtime.close()
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
void cleanup().finally(() => process.exit(0))
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void cleanup().finally(() => process.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -37,6 +37,7 @@ describe('dev proxy config', () => {
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8083',
|
||||
'--no-watch',
|
||||
])
|
||||
|
||||
// Assert
|
||||
@ -45,6 +46,7 @@ describe('dev proxy config', () => {
|
||||
envFile: './.env.proxy',
|
||||
host: '0.0.0.0',
|
||||
port: '8083',
|
||||
watch: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { DotenvOptions } from 'c12'
|
||||
import type { DotenvOptions, LoadConfigOptions, WatchConfigOptions } from 'c12'
|
||||
import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types'
|
||||
import path from 'node:path'
|
||||
import { loadConfig } from 'c12'
|
||||
import { loadConfig, watchConfig } from 'c12'
|
||||
|
||||
const DEFAULT_CONFIG_FILE = 'dev-proxy.config.ts'
|
||||
const DEFAULT_PROXY_HOST = '127.0.0.1'
|
||||
@ -40,6 +40,16 @@ export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOption
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--watch') {
|
||||
options.watch = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--no-watch') {
|
||||
options.watch = false
|
||||
continue
|
||||
}
|
||||
|
||||
const [rawName, inlineValue] = arg.split('=', 2)
|
||||
const name = rawName ?? ''
|
||||
|
||||
@ -105,14 +115,15 @@ const resolveDotenvOptions = (
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDevProxyConfig = async (
|
||||
const createC12ConfigOptions = (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions = {},
|
||||
): Promise<DevProxyConfig> => {
|
||||
): LoadConfigOptions<DevProxyConfig> => {
|
||||
const resolvedConfigPath = path.resolve(cwd, configPath)
|
||||
const parsedPath = path.parse(resolvedConfigPath)
|
||||
const { config: loadedConfig } = await loadConfig({
|
||||
|
||||
return {
|
||||
configFile: parsedPath.name,
|
||||
cwd: parsedPath.dir,
|
||||
dotenv: resolveDotenvOptions(options.envFile, cwd),
|
||||
@ -120,10 +131,34 @@ export const loadDevProxyConfig = async (
|
||||
globalRc: false,
|
||||
packageJson: false,
|
||||
rcFile: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDevProxyConfig = async (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions = {},
|
||||
): Promise<DevProxyConfig> => {
|
||||
const { config: loadedConfig } = await loadConfig({
|
||||
...createC12ConfigOptions(configPath, cwd, options),
|
||||
})
|
||||
|
||||
assertDevProxyConfig(loadedConfig)
|
||||
return loadedConfig
|
||||
}
|
||||
|
||||
export const watchDevProxyConfig = async (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions & Pick<WatchConfigOptions<DevProxyConfig>, 'onUpdate'> = {},
|
||||
) => {
|
||||
const watcher = await watchConfig<DevProxyConfig>({
|
||||
...createC12ConfigOptions(configPath, cwd, options),
|
||||
onUpdate: options.onUpdate,
|
||||
})
|
||||
|
||||
assertDevProxyConfig(watcher.config)
|
||||
return watcher
|
||||
}
|
||||
|
||||
export const defineDevProxyConfig = (config: DevProxyConfig) => config
|
||||
|
||||
@ -39,6 +39,7 @@ export type DevProxyCliOptions = {
|
||||
envFile?: string
|
||||
host?: string
|
||||
port?: string
|
||||
watch?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -255,6 +255,9 @@ catalogs:
|
||||
c12:
|
||||
specifier: 4.0.0-beta.5
|
||||
version: 4.0.0-beta.5
|
||||
chokidar:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@ -699,6 +702,9 @@ importers:
|
||||
c12:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.2.0)(jiti@2.7.0)(magicast@0.5.2)
|
||||
chokidar:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.0
|
||||
hono:
|
||||
specifier: 'catalog:'
|
||||
version: 4.12.18
|
||||
@ -16265,6 +16271,7 @@ time:
|
||||
agentation@3.0.2: '2026-03-25T16:24:19.682Z'
|
||||
ahooks@3.9.7: '2026-03-23T15:49:13.605Z'
|
||||
c12@4.0.0-beta.5: '2026-05-06T17:28:34.367Z'
|
||||
chokidar@5.0.0: '2025-11-25T23:28:06.854Z'
|
||||
class-variance-authority@0.7.1: '2024-11-26T08:20:34.604Z'
|
||||
client-only@0.0.1: '2022-09-03T01:07:11.981Z'
|
||||
clsx@2.1.1: '2024-04-23T05:26:04.645Z'
|
||||
@ -16367,4 +16374,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'
|
||||
|
||||
@ -142,6 +142,7 @@ catalog:
|
||||
agentation: 3.0.2
|
||||
ahooks: 3.9.7
|
||||
c12: 4.0.0-beta.5
|
||||
chokidar: 5.0.0
|
||||
class-variance-authority: 0.7.1
|
||||
client-only: 0.0.1
|
||||
clsx: 2.1.1
|
||||
|
||||
@ -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((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
@ -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,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
|
||||
@ -9,12 +9,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'
|
||||
|
||||
@ -60,8 +58,6 @@ const DocumentTableRow = React.memo(({
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const documentNameId = React.useId()
|
||||
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 : ''
|
||||
@ -115,25 +111,23 @@ const DocumentTableRow = 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}
|
||||
|
||||
@ -11,7 +11,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'
|
||||
@ -50,7 +49,6 @@ const DocumentList = ({
|
||||
}: 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
|
||||
@ -186,14 +184,14 @@ const DocumentList = ({
|
||||
<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' })}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import Link from '@/next/link'
|
||||
|
||||
@ -6,28 +5,13 @@ type OptionProps = {
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
text: string
|
||||
href: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Option = ({
|
||||
Icon,
|
||||
text,
|
||||
href,
|
||||
disabled = false,
|
||||
}: OptionProps) => {
|
||||
if (disabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full cursor-not-allowed items-center gap-x-2 rounded-lg bg-transparent px-4 py-2 text-text-tertiary opacity-50 shadow-shadow-shadow-3',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="grow text-left system-sm-medium">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
type="button"
|
||||
|
||||
@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
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'
|
||||
import InstalledApp from '../index'
|
||||
@ -12,7 +12,7 @@ import InstalledApp from '../index'
|
||||
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(),
|
||||
}))
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
|
||||
@ -7,7 +7,7 @@ import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
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'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
|
||||
@ -1,252 +0,0 @@
|
||||
import type { PermissionGroup } from '@/models/access-control'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PermissionGroupList from '../permission-group-list'
|
||||
|
||||
const createPermissionGroup = (overrides: Partial<PermissionGroup> = {}): PermissionGroup => ({
|
||||
group_key: 'app_management',
|
||||
group_name: 'App management',
|
||||
description: '',
|
||||
permissions: [
|
||||
{
|
||||
key: 'app.dsl.import',
|
||||
name: 'Import DSL',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
key: 'app.dsl.export',
|
||||
name: 'Export DSL',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const permissionGroups = [
|
||||
createPermissionGroup(),
|
||||
createPermissionGroup({
|
||||
group_key: 'api_access',
|
||||
group_name: 'API access',
|
||||
permissions: [
|
||||
{
|
||||
key: 'app.api.view',
|
||||
name: 'View API',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
describe('PermissionGroupList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering keeps the permission catalog visible as grouped collapsible rows.
|
||||
describe('Rendering', () => {
|
||||
it('should render the list with a fixed scroll height', () => {
|
||||
const { container } = render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={[]}
|
||||
onChange={vi.fn()}
|
||||
height={280}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('div[style*="height: 280px"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an empty state when there are no permission groups', () => {
|
||||
render(<PermissionGroupList groups={[]} value={[]} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('permission.permissionList.noPermissionsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand the first selected group by default', () => {
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.api.view']}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /API access/ })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'View API' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.queryByRole('checkbox', { name: 'Import DSL' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Group rows can be expanded and collapsed without changing selected values.
|
||||
describe('Group Interaction', () => {
|
||||
it('should toggle a group when clicking its header', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PermissionGroupList groups={permissionGroups} value={[]} onChange={vi.fn()} />)
|
||||
|
||||
const apiGroupButton = screen.getByRole('button', { name: /API access/ })
|
||||
expect(apiGroupButton).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(apiGroupButton)
|
||||
|
||||
expect(apiGroupButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'View API' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle a group when clicking its arrow control', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PermissionGroupList groups={permissionGroups} value={[]} onChange={vi.fn()} />)
|
||||
|
||||
const apiGroupButton = screen.getByRole('button', { name: /API access/ })
|
||||
expect(apiGroupButton).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'permission.permissionList.expandGroup' }))
|
||||
|
||||
expect(apiGroupButton).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Checkbox interactions update only the selected permission key set.
|
||||
describe('Permission Interaction', () => {
|
||||
it('should add a permission when clicking an unchecked item row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.export']}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Import DSL'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(['app.dsl.export', 'app.dsl.import'])
|
||||
})
|
||||
|
||||
it('should remove a permission once when clicking its checkbox directly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.export']}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const exportDslCheckbox = screen.getByRole('checkbox', { name: 'Export DSL' })
|
||||
await user.click(exportDslCheckbox)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should show selected counts on groups with selected permissions', () => {
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.export']}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const appManagementRow = screen.getByRole('button', { name: /App management/ }).parentElement!
|
||||
expect(within(appManagementRow).getByText('1/2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should select all permissions in a group without toggling expansion', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(<PermissionGroupList groups={permissionGroups} value={[]} onChange={handleChange} />)
|
||||
|
||||
const appManagementButton = screen.getByRole('button', { name: /App management/ })
|
||||
const appManagementRow = appManagementButton.parentElement!
|
||||
await user.click(within(appManagementRow).getByRole('button', { name: 'permission.permissionList.selectAll' }))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(['app.dsl.import', 'app.dsl.export'])
|
||||
expect(appManagementButton).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('should clear all permissions in a fully selected group', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.import', 'app.dsl.export', 'app.api.view']}
|
||||
onChange={handleChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const appManagementRow = screen.getByRole('button', { name: /App management/ }).parentElement!
|
||||
await user.click(within(appManagementRow).getByRole('button', { name: 'permission.permissionList.clearAll' }))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(['app.api.view'])
|
||||
})
|
||||
})
|
||||
|
||||
// Read-only mode still allows browsing groups but blocks permission mutation.
|
||||
describe('Read-only Mode', () => {
|
||||
it('should hide group bulk actions in read-only mode', () => {
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.export']}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'permission.permissionList.selectAll' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'permission.permissionList.clearAll' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not change permissions when clicking a row or checkbox in read-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={['app.dsl.export']}
|
||||
onChange={handleChange}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Import DSL'))
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Export DSL' }))
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should still allow expanding groups in read-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<PermissionGroupList
|
||||
groups={permissionGroups}
|
||||
value={[]}
|
||||
onChange={vi.fn()}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
const apiGroupButton = screen.getByRole('button', { name: /API access/ })
|
||||
await user.click(apiGroupButton)
|
||||
|
||||
expect(apiGroupButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'View API' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicy } from '@/models/access-control'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useCopyAccessRule, useDeleteAccessRule } from '@/service/access-control/use-workspace-access-rules'
|
||||
|
||||
export type AccessRuleRowMenuProps = {
|
||||
rule: AccessPolicy
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
const AccessRuleRowMenu = ({
|
||||
rule,
|
||||
onEdit,
|
||||
}: AccessRuleRowMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { mutateAsync: copyAccessRule } = useCopyAccessRule(rule.resource_type)
|
||||
const { mutateAsync: deleteAccessRule } = useDeleteAccessRule(rule.resource_type)
|
||||
|
||||
const handleCopyRules = useCallback(() => {
|
||||
copyAccessRule(rule.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule copied successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [copyAccessRule, rule.id])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteAccessRule(rule.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule deleted successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [deleteAccessRule, rule.id])
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={open ? 'bg-state-base-hover' : ''}
|
||||
aria-label="More actions"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[140px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="system-sm-semibold text-text-secondary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="system-sm-semibold text-text-secondary"
|
||||
onClick={handleCopyRules}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="system-sm-semibold"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessRuleRowMenu
|
||||
@ -1,106 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyWithBindings, BindingType, RemoveBindingPayload } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useCallback } from 'react'
|
||||
import AccessRuleRowMenu from './access-rule-row-menu'
|
||||
import RoleTag from './role-tag'
|
||||
|
||||
export type AccessRuleRowProps = {
|
||||
rule: AccessPolicyWithBindings
|
||||
canManage: boolean
|
||||
className?: string
|
||||
showMenu?: boolean
|
||||
onEdit?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
onRemove?: (payload: RemoveBindingPayload) => void
|
||||
}
|
||||
|
||||
const AccessRuleRow = ({
|
||||
rule,
|
||||
canManage,
|
||||
className,
|
||||
showMenu = true,
|
||||
onEdit,
|
||||
onAddRole,
|
||||
onRemove,
|
||||
}: AccessRuleRowProps) => {
|
||||
const { policy, roles, accounts } = rule
|
||||
const { id: policyId, resource_type } = policy
|
||||
|
||||
const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule])
|
||||
const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule])
|
||||
|
||||
const handleRemove = useCallback((id: string, type: BindingType) => {
|
||||
if (!onRemove)
|
||||
return
|
||||
|
||||
const payload: RemoveBindingPayload = {
|
||||
policy_id: policyId,
|
||||
resource_type,
|
||||
role_ids: roles.map(role => role.role_id),
|
||||
account_ids: accounts.map(account => account.account_id),
|
||||
}
|
||||
if (type === 'role') {
|
||||
payload.role_ids = payload.role_ids.filter(roleId => roleId !== id)
|
||||
}
|
||||
else if (type === 'account') {
|
||||
payload.account_ids = payload.account_ids.filter(accountId => accountId !== id)
|
||||
}
|
||||
onRemove(payload)
|
||||
}, [accounts, onRemove, policyId, resource_type, roles])
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start gap-2 py-3.5', className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{policy.name}
|
||||
</div>
|
||||
<p className="mt-0.5 system-xs-regular text-text-tertiary">
|
||||
{policy.description}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{roles.map(role => (
|
||||
<RoleTag
|
||||
key={role.role_id}
|
||||
id={role.role_id}
|
||||
label={role.role_name}
|
||||
type="role"
|
||||
showRemove={canManage}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
{accounts.map(account => (
|
||||
<RoleTag
|
||||
key={account.account_id}
|
||||
id={account.account_id}
|
||||
label={account.account_name}
|
||||
type="account"
|
||||
showRemove={canManage}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddRole}
|
||||
className="inline-flex h-6 items-center gap-0.5 rounded-md border border-divider-deep px-1.5 system-xs-medium text-text-tertiary hover:border-divider-solid hover:text-text-secondary"
|
||||
aria-label={`Add role to ${policy.name}`}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line h-3 w-3" />
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showMenu && canManage && (
|
||||
<AccessRuleRowMenu
|
||||
onEdit={handleEdit}
|
||||
rule={policy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AccessRuleRow)
|
||||
@ -1,99 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyWithBindings, RemoveBindingPayload } from '@/models/access-control'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import {
|
||||
useUpdateAppAccessRuleBindings,
|
||||
useUpdateDatasetAccessRuleBindings,
|
||||
} from '@/service/access-control/use-workspace-access-rules'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import AccessRuleRow from './access-rule-row'
|
||||
|
||||
type AccessRuleSectionProps = {
|
||||
title: string
|
||||
rules: AccessPolicyWithBindings[]
|
||||
isLoadingRules: boolean
|
||||
onCreate?: () => void
|
||||
onEditRule?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AccessRuleSection = ({
|
||||
title,
|
||||
rules,
|
||||
isLoadingRules,
|
||||
onCreate,
|
||||
onEditRule,
|
||||
onAddRole,
|
||||
className,
|
||||
}: AccessRuleSectionProps) => {
|
||||
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
|
||||
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
|
||||
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
|
||||
|
||||
const handleRemoveRole = useCallback((payload: RemoveBindingPayload) => {
|
||||
const { policy_id, resource_type, role_ids, account_ids } = payload
|
||||
const updatePayload = {
|
||||
id: policy_id,
|
||||
role_ids,
|
||||
account_ids,
|
||||
}
|
||||
if (resource_type === 'app') {
|
||||
updateAppAccessRuleBindings(updatePayload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (resource_type === 'dataset') {
|
||||
updateDatasetAccessRuleBindings(updatePayload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [updateAppAccessRuleBindings, updateDatasetAccessRuleBindings])
|
||||
|
||||
const canManage = hasPermission(workspacePermissionKeys, 'workspace.role.manage')
|
||||
|
||||
return (
|
||||
<section className={cn('flex flex-col', className)}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 className="pr-3 system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{title}
|
||||
</h3>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={onCreate}
|
||||
disabled={isLoadingRules}
|
||||
>
|
||||
<span className="mr-0.5 i-ri-add-line size-4" />
|
||||
<span>New permission set</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
{rules.map((rule, index) => (
|
||||
<AccessRuleRow
|
||||
key={rule.policy.id}
|
||||
rule={rule}
|
||||
canManage={canManage}
|
||||
className={cn(index > 0 && 'border-t border-divider-subtle')}
|
||||
onEdit={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
onRemove={handleRemoveRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AccessRuleSection)
|
||||
@ -1,158 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useCallback, useState } from 'react'
|
||||
import RolesTab from '../../workspace-role-checkbox-list'
|
||||
import MembersTab from './members-tab'
|
||||
|
||||
type TabKey = 'roles' | 'members'
|
||||
|
||||
type AddRuleTargetsModalBaseProps = {
|
||||
ruleName?: string
|
||||
initialRoleIds?: string[]
|
||||
initialMemberIds?: string[]
|
||||
onClose: () => void
|
||||
onSubmit: (selection: { roleIds: string[], memberIds: string[] }) => void
|
||||
}
|
||||
|
||||
export type AddRuleTargetsModalProps = AddRuleTargetsModalBaseProps
|
||||
|
||||
const TABS: Array<{ key: TabKey, label: string }> = [
|
||||
{ key: 'roles', label: 'ROLES' },
|
||||
{ key: 'members', label: 'MEMBERS' },
|
||||
]
|
||||
|
||||
const AddRuleTargetsModalBody = ({
|
||||
ruleName,
|
||||
initialRoleIds = [],
|
||||
initialMemberIds = [],
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddRuleTargetsModalBaseProps) => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('roles')
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
onSubmit({ roleIds: selectedRoleIds, memberIds: selectedMemberIds })
|
||||
onClose()
|
||||
}, [onClose, onSubmit, selectedMemberIds, selectedRoleIds])
|
||||
|
||||
const description = ruleName
|
||||
? `Select roles or members to grant the "${ruleName}" access level by default.`
|
||||
: 'Select roles or members to grant this access level by default.'
|
||||
|
||||
const summary = (() => {
|
||||
const parts: string[] = []
|
||||
parts.push(`${selectedRoleIds.length} ${selectedRoleIds.length === 1 ? 'role' : 'roles'}`)
|
||||
parts.push(`${selectedMemberIds.length} ${selectedMemberIds.length === 1 ? 'member' : 'members'} selected`)
|
||||
return parts.join(', ')
|
||||
})()
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="flex h-132 w-120 flex-col overflow-hidden p-0"
|
||||
backdropProps={{ forceRender: true }}
|
||||
>
|
||||
<div className="relative shrink-0 px-6 pt-6 pb-4">
|
||||
<DialogCloseButton />
|
||||
<div className="pr-8">
|
||||
<DialogTitle className="system-xl-semibold text-text-primary">
|
||||
Add Roles or Members
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-b border-divider-subtle px-6">
|
||||
<div role="tablist" aria-label="Targets" className="flex items-center gap-6">
|
||||
{TABS.map((tab) => {
|
||||
const active = activeTab === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'-mb-px border-b-2 py-2.5 system-sm-semibold-uppercase tracking-wide transition-colors outline-none',
|
||||
active
|
||||
? 'border-components-tab-active text-text-primary'
|
||||
: 'border-transparent text-text-tertiary hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'roles' && (
|
||||
<RolesTab
|
||||
selectedRoleIds={selectedRoleIds}
|
||||
onSelectedRoleIdsChange={setSelectedRoleIds}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'members' && (
|
||||
<MembersTab
|
||||
selectedMemberIds={selectedMemberIds}
|
||||
onSelectedMemberIdsChange={setSelectedMemberIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{summary}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const AddRuleTargetsModal = ({
|
||||
ruleName,
|
||||
initialRoleIds,
|
||||
initialMemberIds,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddRuleTargetsModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<AddRuleTargetsModalBody
|
||||
ruleName={ruleName}
|
||||
initialRoleIds={initialRoleIds}
|
||||
initialMemberIds={initialMemberIds}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRuleTargetsModal
|
||||
@ -1,147 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type AssignableMemberOption = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
type MembersTabProps = {
|
||||
selectedMemberIds: string[]
|
||||
onSelectedMemberIdsChange: (selectedMemberIds: string[]) => void
|
||||
}
|
||||
|
||||
const toMemberOption = (member: Member): AssignableMemberOption => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatarUrl: member.avatar_url ?? member.avatar ?? null,
|
||||
})
|
||||
|
||||
const MembersTab = ({
|
||||
selectedMemberIds,
|
||||
onSelectedMemberIdsChange,
|
||||
}: MembersTabProps) => {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
const { data: membersData, isLoading: membersLoading } = useMembers()
|
||||
|
||||
const members = useMemo<AssignableMemberOption[]>(() => {
|
||||
const accounts = membersData?.accounts ?? []
|
||||
return accounts
|
||||
.filter(account => account.status !== 'banned' && account.status !== 'closed')
|
||||
.map(toMemberOption)
|
||||
}, [membersData])
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const trimmed = keyword.trim().toLowerCase()
|
||||
if (!trimmed)
|
||||
return members
|
||||
|
||||
return members.filter(
|
||||
member =>
|
||||
member.name.toLowerCase().includes(trimmed)
|
||||
|| member.email.toLowerCase().includes(trimmed),
|
||||
)
|
||||
}, [members, keyword])
|
||||
|
||||
const toggleMember = (id: string) => {
|
||||
onSelectedMemberIdsChange(
|
||||
selectedMemberIds.includes(id)
|
||||
? selectedMemberIds.filter(selectedId => selectedId !== id)
|
||||
: [...selectedMemberIds, id],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 px-6 pt-3 pb-2">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onClear={() => setKeyword('')}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
|
||||
>
|
||||
{membersLoading
|
||||
? (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
Loading members...
|
||||
</div>
|
||||
)
|
||||
: filteredMembers.length === 0
|
||||
? (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
No matching members
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ul className="flex flex-col gap-0.5 pb-2">
|
||||
{filteredMembers.map((member) => {
|
||||
const checked = selectedMemberIds.includes(member.id)
|
||||
const handleToggle = () => toggleMember(member.id)
|
||||
|
||||
return (
|
||||
<li key={member.id}>
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
|
||||
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={member.avatarUrl ?? null}
|
||||
name={member.name}
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate system-xs-regular text-text-tertiary">
|
||||
{member.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MembersTab
|
||||
@ -1,40 +0,0 @@
|
||||
import type { AccessPolicyWithBindings } from '@/models/access-control'
|
||||
import {
|
||||
useWorkspaceAppAccessRules,
|
||||
} from '@/service/access-control/use-workspace-access-rules'
|
||||
import AccessRuleSection from './access-rule-section'
|
||||
|
||||
type AppAccessRuleSectionProps = {
|
||||
className?: string
|
||||
onCreate?: () => void
|
||||
onEditRule?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
}
|
||||
|
||||
const AppAccessRuleSection = ({
|
||||
className,
|
||||
onCreate,
|
||||
onEditRule,
|
||||
onAddRole,
|
||||
}: AppAccessRuleSectionProps) => {
|
||||
const { data: appAccessRulesResponse, isLoading } = useWorkspaceAppAccessRules({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const appAccessRules = appAccessRulesResponse?.items || []
|
||||
|
||||
return (
|
||||
<AccessRuleSection
|
||||
title="App Access Rules"
|
||||
rules={appAccessRules}
|
||||
isLoadingRules={isLoading}
|
||||
onCreate={onCreate}
|
||||
onEditRule={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppAccessRuleSection
|
||||
@ -1,38 +0,0 @@
|
||||
import type { AccessPolicyWithBindings } from '@/models/access-control'
|
||||
import { useWorkspaceDatasetAccessRules } from '@/service/access-control/use-workspace-access-rules'
|
||||
import AccessRuleSection from './access-rule-section'
|
||||
|
||||
type DatasetAccessRuleSectionProps = {
|
||||
className?: string
|
||||
onCreate?: () => void
|
||||
onEditRule?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
}
|
||||
|
||||
const DatasetAccessRuleSection = ({
|
||||
className,
|
||||
onCreate,
|
||||
onEditRule,
|
||||
onAddRole,
|
||||
}: DatasetAccessRuleSectionProps) => {
|
||||
const { data: datasetAccessRulesResponse, isLoading } = useWorkspaceDatasetAccessRules({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const datasetAccessRules = datasetAccessRulesResponse?.items || []
|
||||
|
||||
return (
|
||||
<AccessRuleSection
|
||||
title="Knowledge Base Access Rules"
|
||||
rules={datasetAccessRules}
|
||||
isLoadingRules={isLoading}
|
||||
onCreate={onCreate}
|
||||
onEditRule={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetAccessRuleSection
|
||||
@ -1,176 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { PermissionSetFormValues, PermissionSetModalMode } from './permission-set-modal'
|
||||
import type { AccessPolicyResourceType, AccessPolicyWithBindings } from '@/models/access-control'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCreateAccessRule, useUpdateAccessRule, useUpdateAppAccessRuleBindings, useUpdateDatasetAccessRuleBindings } from '@/service/access-control/use-workspace-access-rules'
|
||||
import AddRuleTargetsModal from './add-rule-targets-modal'
|
||||
import AppAccessRuleSection from './app-access-rule-section'
|
||||
import DatasetAccessRuleSection from './dataset-access-rule-section'
|
||||
import PermissionSetModal from './permission-set-modal'
|
||||
|
||||
type PermissionSetModalState = {
|
||||
mode: PermissionSetModalMode
|
||||
resourceType: AccessPolicyResourceType
|
||||
ruleId?: string
|
||||
initialValues?: PermissionSetFormValues
|
||||
}
|
||||
|
||||
const AccessRulesPage = () => {
|
||||
const [addingRule, setAddingRule] = useState<AccessPolicyWithBindings | null>(null)
|
||||
const [permissionSetModalState, setPermissionSetModalState]
|
||||
= useState<PermissionSetModalState | null>(null)
|
||||
|
||||
const closeAddModal = useCallback(() => {
|
||||
setAddingRule(null)
|
||||
}, [])
|
||||
|
||||
const closePermissionSetModal = useCallback(() => {
|
||||
setPermissionSetModalState(null)
|
||||
}, [])
|
||||
|
||||
const handleAddRole = useCallback((rule: AccessPolicyWithBindings) => {
|
||||
setAddingRule(rule)
|
||||
}, [])
|
||||
|
||||
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
|
||||
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
|
||||
|
||||
const handleAddSubmit = useCallback(
|
||||
(selection: { roleIds: string[], memberIds: string[] }) => {
|
||||
const { id, resource_type } = addingRule!.policy
|
||||
const payload = {
|
||||
id,
|
||||
role_ids: selection.roleIds,
|
||||
account_ids: selection.memberIds,
|
||||
}
|
||||
if (resource_type === 'app') {
|
||||
updateAppAccessRuleBindings(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
closeAddModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (resource_type === 'dataset') {
|
||||
updateDatasetAccessRuleBindings(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
closeAddModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[addingRule, closeAddModal, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings],
|
||||
)
|
||||
|
||||
const handleCreate = useCallback((resourceType: AccessPolicyResourceType) => {
|
||||
setPermissionSetModalState({ mode: 'create', resourceType })
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(resourceType: AccessPolicyResourceType, rule: AccessPolicyWithBindings) => {
|
||||
const { policy } = rule
|
||||
setPermissionSetModalState({
|
||||
mode: 'edit',
|
||||
resourceType,
|
||||
ruleId: policy.id,
|
||||
initialValues: {
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
permissionKeys: policy.permission_keys,
|
||||
},
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const { mutateAsync: createAccessRule } = useCreateAccessRule()
|
||||
const { mutateAsync: updateAccessRule } = useUpdateAccessRule()
|
||||
|
||||
const handlePermissionSetSubmit = useCallback(
|
||||
(values: PermissionSetFormValues) => {
|
||||
const mode = permissionSetModalState?.mode || ''
|
||||
const id = permissionSetModalState?.ruleId || ''
|
||||
const { name, description, permissionKeys } = values
|
||||
if (mode === 'create') {
|
||||
createAccessRule({
|
||||
name,
|
||||
description,
|
||||
permission_keys: permissionKeys,
|
||||
resourceType: permissionSetModalState!.resourceType,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule created successfully')
|
||||
closePermissionSetModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (mode === 'edit') {
|
||||
updateAccessRule({
|
||||
id: id!,
|
||||
name,
|
||||
description,
|
||||
permission_keys: permissionKeys,
|
||||
resourceType: permissionSetModalState!.resourceType,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
closePermissionSetModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[closePermissionSetModal, createAccessRule, updateAccessRule, permissionSetModalState],
|
||||
)
|
||||
|
||||
const createApp = useCallback(() => handleCreate('app'), [handleCreate])
|
||||
const createKb = useCallback(() => handleCreate('dataset'), [handleCreate])
|
||||
const editApp = useCallback(
|
||||
(rule: AccessPolicyWithBindings) => handleEdit('app', rule),
|
||||
[handleEdit],
|
||||
)
|
||||
const editKb = useCallback(
|
||||
(rule: AccessPolicyWithBindings) => handleEdit('dataset', rule),
|
||||
[handleEdit],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<AppAccessRuleSection
|
||||
onCreate={createApp}
|
||||
onEditRule={editApp}
|
||||
onAddRole={handleAddRole}
|
||||
/>
|
||||
<DatasetAccessRuleSection
|
||||
onCreate={createKb}
|
||||
onEditRule={editKb}
|
||||
onAddRole={handleAddRole}
|
||||
/>
|
||||
</div>
|
||||
{addingRule && (
|
||||
<AddRuleTargetsModal
|
||||
ruleName={addingRule.policy.name}
|
||||
initialRoleIds={addingRule.roles.map(role => role.role_id)}
|
||||
initialMemberIds={addingRule.accounts.map(account => account.account_id)}
|
||||
onClose={closeAddModal}
|
||||
onSubmit={handleAddSubmit}
|
||||
/>
|
||||
)}
|
||||
{permissionSetModalState && (
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode={permissionSetModalState.mode}
|
||||
resourceType={permissionSetModalState.resourceType}
|
||||
initialValues={permissionSetModalState.initialValues}
|
||||
onClose={closePermissionSetModal}
|
||||
onSubmit={handlePermissionSetSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessRulesPage
|
||||
@ -1,40 +0,0 @@
|
||||
import type { AccessPolicyResourceType } from '@/models/access-control'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppPermissionCatalog, useDatasetPermissionCatalog } from '@/service/access-control/use-permission-catalog'
|
||||
|
||||
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
|
||||
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
|
||||
|
||||
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return (permissionCatalog?.groups || []).map(group => ({
|
||||
...group,
|
||||
group_name: t(`group.${resourceType}_acl`, {
|
||||
ns: 'permission',
|
||||
defaultValue: group.group_name,
|
||||
}),
|
||||
permissions: group.permissions.map(permission => ({
|
||||
...permission,
|
||||
name: t(permission.key, {
|
||||
ns: 'permissionKeys',
|
||||
defaultValue: permission.name,
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
}, [permissionCatalog?.groups, resourceType, t])
|
||||
|
||||
const allPermissions = groups.flatMap(g => g.permissions) || []
|
||||
|
||||
const permissionMap = Object.fromEntries(
|
||||
allPermissions.map(p => [p.key, p]),
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
permissionMap,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user