Compare commits

..

1 Commits

Author SHA1 Message Date
yyh
cf9e649e11 feat(dev-proxy): reload env file changes 2026-05-19 16:08:21 +08:00
238 changed files with 1864 additions and 10451 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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",
}

View File

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

View File

@ -119,11 +119,6 @@
"count": 3
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": {
"react/static-components": {
"count": 2
@ -435,11 +430,6 @@
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/configuration/prompt-value-panel/utils.ts": {
"ts/no-explicit-any": {
"count": 1
@ -495,35 +485,6 @@
"count": 1
}
},
"web/app/components/app/overview/app-card.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/customize/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/app/overview/embedded/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/app/overview/settings/index.tsx": {
"no-restricted-imports": {
"count": 2
},
"react/set-state-in-effect": {
"count": 3
},
"regexp/no-unused-capturing-group": {
"count": 1
}
},
"web/app/components/app/overview/trigger-card.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -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

View File

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

View File

@ -30,6 +30,7 @@
"dependencies": {
"@hono/node-server": "catalog:",
"c12": "catalog:",
"chokidar": "catalog:",
"hono": "catalog:"
},
"devDependencies": {

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,7 @@ export type DevProxyCliOptions = {
envFile?: string
host?: string
port?: string
watch?: boolean
help?: boolean
}

9
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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