mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 05:07:41 +08:00
Compare commits
76 Commits
feat/rbac
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
| c61df1942f | |||
| 0536549f73 | |||
| d0956039e7 | |||
| 38eb04dc98 | |||
| d2e1da269c | |||
| 1d3498f659 | |||
| b8dea56198 | |||
| e2becd6746 | |||
| 28a26f2d59 | |||
| 2566ab9105 | |||
| 8c7393ef46 | |||
| b116faaf10 | |||
| 5a7a955210 | |||
| 3e4849d765 | |||
| 0c280ef708 | |||
| 282561a861 | |||
| cbb4cc5d76 | |||
| 2d6babeeb4 | |||
| 1065a4840a | |||
| b6aa5a7d69 | |||
| 949f930698 | |||
| 65a08ed7ab | |||
| cc4d6db7c8 | |||
| 6b5d6dacb2 | |||
| 89bf75eba9 | |||
| 3a28868a6c | |||
| 4036515abe | |||
| 6c089cab66 | |||
| 818a71d637 | |||
| 3db107edc9 | |||
| 2677d90860 | |||
| 859756c4f6 | |||
| 295fb6e74a | |||
| 2326fb7a83 | |||
| 2d6eaf69f9 | |||
| 3e826c0000 | |||
| b1b977e284 | |||
| 23648141c9 | |||
| d6dee43c09 | |||
| 7efc887e32 | |||
| 8b346e69d9 | |||
| ef7ff3356d | |||
| 7b5c0b5045 | |||
| f00512dd5d | |||
| e6ef774fd5 | |||
| ce50c6cf1c | |||
| 7002512106 | |||
| c3aebb8403 | |||
| 0baefa6163 | |||
| 7bcedcbaab | |||
| 791fc5819d | |||
| 2d09c4788d | |||
| 9bd5c2f8ec | |||
| 5e336c47fd | |||
| be4c828214 | |||
| ec450eb7f9 | |||
| 48e13f65dc | |||
| 38fc2a6574 | |||
| ed8d3f3e8d | |||
| 0c8dec3315 | |||
| 38e831c1b3 | |||
| 1c5d62d98a | |||
| 6b4736bf78 | |||
| c9503fd818 | |||
| 91a1df96cb | |||
| 5b2c5da945 | |||
| b59ecea346 | |||
| 61c0948136 | |||
| f746c7bdf2 | |||
| 2a3deee385 | |||
| 4b6803ba06 | |||
| 4c908c8f39 | |||
| afec528f51 | |||
| 491061b8f4 | |||
| 8b1533438f | |||
| ba924fc97b |
@ -23,11 +23,6 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
||||
)
|
||||
|
||||
RBAC_ENABLED: bool = Field(
|
||||
description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@ -132,7 +132,6 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
rbac,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -200,7 +199,6 @@ __all__ = [
|
||||
"rag_pipeline_draft_variable",
|
||||
"rag_pipeline_import",
|
||||
"rag_pipeline_workflow",
|
||||
"rbac",
|
||||
"recommended_app",
|
||||
"saved_message",
|
||||
"setup",
|
||||
|
||||
@ -27,7 +27,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
|
||||
@ -38,7 +37,6 @@ from models.model import IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import AppService
|
||||
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,
|
||||
@ -332,7 +330,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
|
||||
@ -478,20 +475,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"}
|
||||
]
|
||||
|
||||
@ -57,7 +57,6 @@ from models.enums import ApiTokenType, SegmentStatus
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
|
||||
# Register models for flask_restx to avoid dict type issues in Swagger
|
||||
dataset_base_model = get_or_create_model("DatasetBase", dataset_fields)
|
||||
@ -128,14 +127,6 @@ def _validate_doc_form(value: str | None) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_permission_keys(dataset: Dataset, *, enabled: bool) -> None:
|
||||
if not enabled:
|
||||
setattr(dataset, "permission_keys", [])
|
||||
return
|
||||
if not isinstance(getattr(dataset, "permission_keys", None), list):
|
||||
setattr(dataset, "permission_keys", [])
|
||||
|
||||
|
||||
class DatasetCreatePayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=40)
|
||||
description: str = Field("", max_length=400)
|
||||
@ -338,19 +329,6 @@ class DatasetListApi(Resource):
|
||||
query.include_all,
|
||||
)
|
||||
|
||||
for dataset in datasets:
|
||||
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
|
||||
|
||||
if dify_config.RBAC_ENABLED and datasets:
|
||||
dataset_ids = [str(dataset.id) for dataset in datasets]
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_user.id,
|
||||
dataset_ids,
|
||||
)
|
||||
for dataset in datasets:
|
||||
setattr(dataset, "permission_keys", permission_keys_map.get(str(dataset.id), []))
|
||||
|
||||
# check embedding setting
|
||||
provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id)
|
||||
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
|
||||
@ -432,7 +410,6 @@ class DatasetListApi(Resource):
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
|
||||
return marshal(dataset, dataset_detail_fields), 201
|
||||
|
||||
|
||||
@ -457,7 +434,6 @@ class DatasetApi(Resource):
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
|
||||
data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
|
||||
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
|
||||
if dataset.embedding_model_provider:
|
||||
@ -527,7 +503,6 @@ class DatasetApi(Resource):
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
|
||||
result_data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
|
||||
tenant_id = current_tenant_id
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ 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
|
||||
|
||||
@ -73,19 +72,6 @@ register_enum_models(console_ns, TenantAccountRole)
|
||||
register_schema_models(console_ns, AccountWithRole, AccountWithRoleList)
|
||||
|
||||
|
||||
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."""
|
||||
@ -99,36 +85,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
|
||||
|
||||
|
||||
@ -1,606 +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_WORKSPACE_PERMISSION_KEYS: list[str] = [
|
||||
# These keys are copied from the enterprise RBAC catalog examples in
|
||||
# `dify-rbac.md` so the legacy workspace roles stay in the same key format
|
||||
# as the enterprise RBAC surface.
|
||||
"workspace.member.manage",
|
||||
"workspace.role.manage",
|
||||
]
|
||||
|
||||
_LEGACY_APP_PERMISSION_KEYS: list[str] = [
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
"app.acl.access_config",
|
||||
]
|
||||
|
||||
_LEGACY_DATASET_PERMISSION_KEYS: list[str] = [
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.use",
|
||||
]
|
||||
|
||||
_LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = {
|
||||
# These legacy role groups predate the RBAC refactor. The mapping keeps the
|
||||
# old workspace roles readable through the new RBAC endpoint by translating
|
||||
# each role into the closest enterprise permission keys that already exist
|
||||
# in the catalog and tests.
|
||||
"owner": [
|
||||
*_LEGACY_WORKSPACE_PERMISSION_KEYS,
|
||||
*_LEGACY_APP_PERMISSION_KEYS,
|
||||
*_LEGACY_DATASET_PERMISSION_KEYS,
|
||||
],
|
||||
"admin": [
|
||||
*_LEGACY_WORKSPACE_PERMISSION_KEYS,
|
||||
*_LEGACY_APP_PERMISSION_KEYS,
|
||||
*_LEGACY_DATASET_PERMISSION_KEYS,
|
||||
],
|
||||
"editor": [
|
||||
*_LEGACY_APP_PERMISSION_KEYS,
|
||||
*_LEGACY_DATASET_PERMISSION_KEYS,
|
||||
],
|
||||
"normal": [
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
],
|
||||
"dataset_operator": [
|
||||
*_LEGACY_DATASET_PERMISSION_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())
|
||||
|
||||
|
||||
def _pagination_options() -> svc.ListOption:
|
||||
return _PaginationQuery.model_validate(request.args.to_dict(flat=True)).to_inner_options()
|
||||
|
||||
|
||||
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]),
|
||||
)
|
||||
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()
|
||||
options = _pagination_options()
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return _dump(_legacy_workspace_roles(options))
|
||||
return _dump(svc.RBACService.Roles.list(tenant_id, account_id, options=options))
|
||||
|
||||
@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),
|
||||
)
|
||||
)
|
||||
@ -80,7 +80,6 @@ app_detail_fields = {
|
||||
"updated_at": TimestampField,
|
||||
"access_mode": fields.String,
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"permission_keys": fields.List(fields.String),
|
||||
}
|
||||
|
||||
prompt_config_fields = {
|
||||
@ -118,7 +117,6 @@ app_partial_fields = {
|
||||
"create_user_name": fields.String,
|
||||
"author_name": fields.String,
|
||||
"has_draft_trigger": fields.Boolean,
|
||||
"permission_keys": fields.List(fields.String),
|
||||
}
|
||||
|
||||
|
||||
@ -199,7 +197,6 @@ app_detail_fields_with_site = {
|
||||
"deleted_tools": fields.List(fields.Nested(deleted_tool_fields)),
|
||||
"access_mode": fields.String,
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"permission_keys": fields.List(fields.String),
|
||||
"site": fields.Nested(site_fields),
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@ dataset_fields = {
|
||||
"indexing_technique": fields.String,
|
||||
"created_by": fields.String,
|
||||
"created_at": TimestampField,
|
||||
"permission_keys": fields.List(fields.String),
|
||||
}
|
||||
|
||||
reranking_model_fields = {"reranking_provider_name": fields.String, "reranking_model_name": fields.String}
|
||||
@ -108,7 +107,6 @@ dataset_detail_fields = {
|
||||
"total_available_documents": fields.Integer,
|
||||
"enable_api": fields.Boolean,
|
||||
"is_multimodal": fields.Boolean,
|
||||
"permission_keys": fields.List(fields.String),
|
||||
}
|
||||
|
||||
file_info_fields = {
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from flask_restx import fields
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
from pydantic import computed_field, field_validator
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from graphon.file import helpers as file_helpers
|
||||
@ -70,7 +70,6 @@ class AccountWithRole(_AccountAvatar):
|
||||
last_active_at: int | None = None
|
||||
created_at: int | None = None
|
||||
role: str
|
||||
roles: list[dict[str, str]] = Field(default_factory=list)
|
||||
status: str
|
||||
|
||||
@field_validator("last_login_at", "last_active_at", "created_at", mode="before")
|
||||
|
||||
@ -11,8 +11,6 @@ from sqlalchemy import DateTime, String, func, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
from .base import TypeBase
|
||||
from .engine import db
|
||||
from .types import EnumText, LongText, StringUUID
|
||||
@ -189,14 +187,10 @@ class Account(UserMixin, TypeBase):
|
||||
# check current_user.current_tenant.current_role in ['admin', 'owner']
|
||||
@property
|
||||
def is_admin_or_owner(self):
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return True
|
||||
return TenantAccountRole.is_privileged_role(self.role)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return True
|
||||
return TenantAccountRole.is_admin_role(self.role)
|
||||
|
||||
@property
|
||||
@ -222,20 +216,14 @@ class Account(UserMixin, TypeBase):
|
||||
- `ADMIN`
|
||||
- `EDITOR`
|
||||
"""
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return True
|
||||
return TenantAccountRole.is_editing_role(self.role)
|
||||
|
||||
@property
|
||||
def is_dataset_editor(self):
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return True
|
||||
return TenantAccountRole.is_dataset_edit_role(self.role)
|
||||
|
||||
@property
|
||||
def is_dataset_operator(self):
|
||||
if dify_config.RBAC_ENABLED:
|
||||
return True
|
||||
return self.role == TenantAccountRole.DATASET_OPERATOR
|
||||
|
||||
|
||||
|
||||
@ -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
@ -11,8 +11,6 @@ from typing import Any
|
||||
import pytest
|
||||
from flask.views import MethodView
|
||||
|
||||
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]
|
||||
@ -198,7 +196,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")
|
||||
@ -211,7 +208,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):
|
||||
@ -275,7 +271,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",
|
||||
@ -303,52 +298,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"]
|
||||
|
||||
@ -93,48 +93,6 @@ class TestDatasetList:
|
||||
assert resp["total"] == 1
|
||||
assert resp["data"][0]["embedding_available"] is True
|
||||
|
||||
def test_get_with_rbac_enabled_fetches_permission_keys(self, app):
|
||||
api = DatasetListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
current_user = self._mock_user()
|
||||
current_user.id = "acct-1"
|
||||
dataset = MagicMock(id="ds-1")
|
||||
datasets = [dataset]
|
||||
marshaled = [self._mock_dataset_dict()]
|
||||
|
||||
with app.test_request_context("/datasets"):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.datasets.datasets.current_account_with_tenant",
|
||||
return_value=(current_user, "tenant-1"),
|
||||
),
|
||||
patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True),
|
||||
patch.object(
|
||||
DatasetService,
|
||||
"get_datasets",
|
||||
return_value=(datasets, 1),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetPermissions.batch_get",
|
||||
return_value={"ds-1": ["dataset.acl.readonly", "dataset.acl.edit"]},
|
||||
) as mock_batch_get,
|
||||
patch(
|
||||
"controllers.console.datasets.datasets.marshal",
|
||||
return_value=marshaled,
|
||||
),
|
||||
patch.object(
|
||||
ProviderManager,
|
||||
"get_configurations",
|
||||
return_value=MagicMock(get_models=lambda **_: []),
|
||||
),
|
||||
):
|
||||
resp, status = method(api)
|
||||
|
||||
assert status == 200
|
||||
assert dataset.permission_keys == ["dataset.acl.readonly", "dataset.acl.edit"]
|
||||
mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["ds-1"])
|
||||
|
||||
def test_get_with_ids_filter(self, app):
|
||||
api = DatasetListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -46,8 +45,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 (
|
||||
@ -59,53 +58,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):
|
||||
api = MemberListApi()
|
||||
|
||||
@ -1,270 +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"),
|
||||
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": [
|
||||
"workspace.member.manage",
|
||||
"workspace.role.manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
"app.acl.access_config",
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.use",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"tenant_id": "",
|
||||
"type": "workspace",
|
||||
"category": "global_system_default",
|
||||
"name": "admin",
|
||||
"description": "",
|
||||
"is_builtin": True,
|
||||
"permission_keys": [
|
||||
"workspace.member.manage",
|
||||
"workspace.role.manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
"app.acl.access_config",
|
||||
"dataset.acl.readonly",
|
||||
"dataset.acl.edit",
|
||||
"dataset.acl.use",
|
||||
],
|
||||
},
|
||||
]
|
||||
assert response["pagination"] == {
|
||||
"total_count": 5,
|
||||
"per_page": 2,
|
||||
"current_page": 1,
|
||||
"total_pages": 3,
|
||||
}
|
||||
mock_list.assert_not_called()
|
||||
|
||||
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"),
|
||||
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", True),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi())
|
||||
|
||||
_, kwargs = mock_list.call_args
|
||||
options = kwargs["options"]
|
||||
assert options.page_number == 2
|
||||
assert options.results_per_page == 50
|
||||
assert options.reverse is True
|
||||
|
||||
def test_access_policies_get_forwards_outer_pagination_params(self, app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/workspaces/current/rbac/access-policies?resource_type=app&page=3&limit=25&reverse=false"
|
||||
),
|
||||
_enabled(True),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.list") as mock_list,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACAccessPoliciesApi.get)(rbac_mod.RBACAccessPoliciesApi())
|
||||
|
||||
_, kwargs = mock_list.call_args
|
||||
assert kwargs["resource_type"] == "app"
|
||||
options = kwargs["options"]
|
||||
assert options.page_number == 3
|
||||
assert options.results_per_page == 25
|
||||
assert options.reverse is False
|
||||
|
||||
def test_workspace_app_matrix_forwards_outer_pagination_params(self, app):
|
||||
with (
|
||||
app.test_request_context("/workspaces/current/rbac/workspace/apps/access-policy?page=4&limit=10"),
|
||||
_enabled(True),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.app_matrix") as mock_list,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACWorkspaceAppMatrixApi.get)(rbac_mod.RBACWorkspaceAppMatrixApi())
|
||||
|
||||
_, kwargs = mock_list.call_args
|
||||
options = kwargs["options"]
|
||||
assert options.page_number == 4
|
||||
assert options.results_per_page == 10
|
||||
assert options.reverse is None
|
||||
|
||||
def test_workspace_dataset_matrix_forwards_outer_pagination_params(self, app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/workspaces/current/rbac/workspace/datasets/access-policy?page=5&limit=15&reverse=true"
|
||||
),
|
||||
_enabled(True),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.dataset_matrix")
|
||||
as mock_list,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACWorkspaceDatasetMatrixApi.get)(rbac_mod.RBACWorkspaceDatasetMatrixApi())
|
||||
|
||||
_, kwargs = mock_list.call_args
|
||||
options = kwargs["options"]
|
||||
assert options.page_number == 5
|
||||
assert options.results_per_page == 15
|
||||
assert options.reverse is True
|
||||
|
||||
|
||||
class TestRoleCopy:
|
||||
def test_role_copy_forwards_path_id(self, app):
|
||||
with (
|
||||
app.test_request_context("/workspaces/current/rbac/roles/role-1/copy", method="POST"),
|
||||
_enabled(True),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.copy") as mock_copy,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACRoleCopyApi.post)(rbac_mod.RBACRoleCopyApi(), "role-1")
|
||||
|
||||
mock_copy.assert_called_once_with("tenant-1", "acct-1", "role-1")
|
||||
|
||||
|
||||
class TestDumpHelper:
|
||||
def test_dump_returns_plain_dict(self):
|
||||
role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", name="Owner")
|
||||
dumped = rbac_mod._dump(role)
|
||||
assert isinstance(dumped, dict)
|
||||
assert "role_id" not in dumped
|
||||
@ -13,7 +13,6 @@ import base64
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@ -348,15 +347,7 @@ class TestAccountRolePermissions:
|
||||
account.role = TenantAccountRole.ADMIN
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert account.is_admin_or_owner
|
||||
|
||||
def test_is_admin_or_owner_with_rbac_enabled(self):
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", True):
|
||||
assert account.is_admin_or_owner
|
||||
assert account.is_admin_or_owner
|
||||
|
||||
def test_is_admin_or_owner_with_owner_role(self):
|
||||
"""Test is_admin_or_owner property with owner role."""
|
||||
@ -392,16 +383,8 @@ class TestAccountRolePermissions:
|
||||
owner_account.role = TenantAccountRole.OWNER
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert admin_account.is_admin
|
||||
assert not owner_account.is_admin
|
||||
|
||||
def test_is_admin_with_rbac_enabled(self):
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", True):
|
||||
assert account.is_admin
|
||||
assert admin_account.is_admin
|
||||
assert not owner_account.is_admin
|
||||
|
||||
def test_has_edit_permission_with_editing_roles(self):
|
||||
"""Test has_edit_permission property with roles that have edit permission."""
|
||||
@ -417,15 +400,7 @@ class TestAccountRolePermissions:
|
||||
account.role = role
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert account.has_edit_permission, f"Role {role} should have edit permission"
|
||||
|
||||
def test_has_edit_permission_with_rbac_enabled(self):
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", True):
|
||||
assert account.has_edit_permission
|
||||
assert account.has_edit_permission, f"Role {role} should have edit permission"
|
||||
|
||||
def test_has_edit_permission_without_editing_roles(self):
|
||||
"""Test has_edit_permission property with roles that don't have edit permission."""
|
||||
@ -440,8 +415,7 @@ class TestAccountRolePermissions:
|
||||
account.role = role
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
|
||||
assert not account.has_edit_permission, f"Role {role} should not have edit permission"
|
||||
|
||||
def test_is_dataset_editor_property(self):
|
||||
"""Test is_dataset_editor property."""
|
||||
@ -458,21 +432,12 @@ class TestAccountRolePermissions:
|
||||
account.role = role
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
|
||||
assert account.is_dataset_editor, f"Role {role} should have dataset edit permission"
|
||||
|
||||
# Test normal role doesn't have dataset edit permission
|
||||
normal_account = Account(name="Normal User", email="normal@example.com")
|
||||
normal_account.role = TenantAccountRole.NORMAL
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert not normal_account.is_dataset_editor
|
||||
|
||||
def test_is_dataset_editor_with_rbac_enabled(self):
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", True):
|
||||
assert account.is_dataset_editor
|
||||
assert not normal_account.is_dataset_editor
|
||||
|
||||
def test_is_dataset_operator_property(self):
|
||||
"""Test is_dataset_operator property."""
|
||||
@ -484,16 +449,8 @@ class TestAccountRolePermissions:
|
||||
normal_account.role = TenantAccountRole.NORMAL
|
||||
|
||||
# Act & Assert
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", False):
|
||||
assert dataset_operator.is_dataset_operator
|
||||
assert not normal_account.is_dataset_operator
|
||||
|
||||
def test_is_dataset_operator_with_rbac_enabled(self):
|
||||
account = Account(name="Test User", email="test@example.com")
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
|
||||
with patch("models.account.dify_config.RBAC_ENABLED", True):
|
||||
assert account.is_dataset_operator
|
||||
assert dataset_operator.is_dataset_operator
|
||||
assert not normal_account.is_dataset_operator
|
||||
|
||||
def test_current_role_property(self):
|
||||
"""Test current_role property."""
|
||||
|
||||
@ -1,568 +0,0 @@
|
||||
"""Unit tests for services.enterprise.rbac_service.
|
||||
|
||||
The enterprise RBAC client is almost pure glue: each method turns a single
|
||||
``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response
|
||||
model. Rather than spinning up an HTTP server we monkeypatch that helper and
|
||||
assert on the arguments it received; that catches both routing regressions
|
||||
(wrong method / wrong path / wrong params) and model-shape regressions in
|
||||
one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.enterprise import rbac_service as svc
|
||||
|
||||
MODULE = "services.enterprise.rbac_service"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send():
|
||||
with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send:
|
||||
yield send
|
||||
|
||||
|
||||
def _call_args(send: MagicMock) -> SimpleNamespace:
|
||||
"""Return the most recent (method, endpoint, kwargs) sent to the mock."""
|
||||
send.assert_called_once()
|
||||
args, kwargs = send.call_args
|
||||
return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs)
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
def test_workspace_catalog(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]}
|
||||
|
||||
out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1")
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/role-permissions/catalog"
|
||||
assert call.tenant_id == "tenant-1"
|
||||
assert call.account_id == "acct-1"
|
||||
assert call.json is None
|
||||
assert call.params is None
|
||||
assert len(out.groups) == 1
|
||||
assert out.groups[0].group_key == "workspace"
|
||||
|
||||
def test_app_catalog_endpoint(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"groups": []}
|
||||
svc.RBACService.Catalog.app("tenant-1")
|
||||
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app"
|
||||
|
||||
def test_dataset_catalog_endpoint(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"groups": []}
|
||||
svc.RBACService.Catalog.dataset("tenant-1")
|
||||
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset"
|
||||
|
||||
|
||||
class TestRoles:
|
||||
def test_list_forwards_pagination_options(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "role-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"type": "workspace",
|
||||
"category": "global_custom",
|
||||
"name": "Owner",
|
||||
"permission_keys": ["workspace.member.manage"],
|
||||
}
|
||||
],
|
||||
"pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1},
|
||||
}
|
||||
|
||||
out = svc.RBACService.Roles.list(
|
||||
"tenant-1",
|
||||
"acct-1",
|
||||
options=svc.ListOption(page_number=2, results_per_page=50, reverse=True),
|
||||
)
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/roles"
|
||||
assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
|
||||
assert out.pagination and out.pagination.total_count == 1
|
||||
|
||||
def test_list_omits_params_when_default(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": [], "pagination": None}
|
||||
svc.RBACService.Roles.list("tenant-1")
|
||||
assert _call_args(mock_send).params is None
|
||||
|
||||
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "role-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"type": "workspace",
|
||||
"category": "global_custom",
|
||||
"name": "Owner",
|
||||
"permission_keys": None,
|
||||
}
|
||||
],
|
||||
"pagination": None,
|
||||
}
|
||||
|
||||
out = svc.RBACService.Roles.list("tenant-1")
|
||||
|
||||
assert out.data[0].permission_keys == []
|
||||
|
||||
def test_get_passes_id_query_param(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
|
||||
svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/roles/item"
|
||||
assert call.params == {"id": "role-1"}
|
||||
|
||||
def test_create_sends_body(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
|
||||
payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"])
|
||||
svc.RBACService.Roles.create("tenant-1", "acct-1", payload)
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/roles"
|
||||
assert call.json == {
|
||||
"name": "Owner",
|
||||
"description": "full access",
|
||||
"permission_keys": ["workspace.member.manage"],
|
||||
"type": "workspace",
|
||||
}
|
||||
|
||||
def test_update_sends_id_param_and_body(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
|
||||
payload = svc.RoleMutation(name="Owner", permission_keys=["x"])
|
||||
svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload)
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/roles/item"
|
||||
assert call.params == {"id": "role-1"}
|
||||
assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"}
|
||||
|
||||
def test_delete_uses_delete_method(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"message": "success"}
|
||||
svc.RBACService.Roles.delete("tenant-1", None, "role-1")
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "DELETE"
|
||||
assert call.endpoint == "/rbac/roles/item"
|
||||
assert call.params == {"id": "role-1"}
|
||||
assert call.account_id is None
|
||||
|
||||
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"}
|
||||
svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1")
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/roles/copy"
|
||||
assert call.params == {"id": "role-1"}
|
||||
assert call.account_id == "acct-1"
|
||||
|
||||
|
||||
class TestAccessPolicies:
|
||||
def test_list_filters_by_resource_type(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": [], "pagination": None}
|
||||
svc.RBACService.AccessPolicies.list(
|
||||
"tenant-1",
|
||||
"acct-1",
|
||||
resource_type=svc.RBACResourceType.APP,
|
||||
options=svc.ListOption(page_number=1),
|
||||
)
|
||||
call = _call_args(mock_send)
|
||||
assert call.endpoint == "/rbac/access-policies"
|
||||
assert call.params == {"page_number": 1, "resource_type": "app"}
|
||||
|
||||
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"id": "policy-1-copy",
|
||||
"resource_type": "app",
|
||||
"name": "Full access copy",
|
||||
}
|
||||
svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/access-policies/copy"
|
||||
assert call.params == {"id": "policy-1"}
|
||||
|
||||
def test_create_serialises_resource_type_enum(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"}
|
||||
payload = svc.AccessPolicyCreate(
|
||||
name="KB only",
|
||||
resource_type=svc.RBACResourceType.DATASET,
|
||||
permission_keys=["dataset.acl.readonly"],
|
||||
)
|
||||
svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.json == {
|
||||
"name": "KB only",
|
||||
"resource_type": "dataset",
|
||||
"description": "",
|
||||
"permission_keys": ["dataset.acl.readonly"],
|
||||
}
|
||||
|
||||
|
||||
class TestResourceAccess:
|
||||
def test_app_matrix(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"app_id": "app-1", "items": []}
|
||||
out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/apps/access-policy"
|
||||
assert call.params == {"app_id": "app-1"}
|
||||
assert out.app_id == "app-1"
|
||||
|
||||
def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "binding-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"access_policy_id": "policy-1",
|
||||
"resource_type": "app",
|
||||
"resource_id": "app-1",
|
||||
"role_id": "role-1",
|
||||
"role_name": "Owner",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1")
|
||||
|
||||
assert out.data[0].role_name == "Owner"
|
||||
|
||||
def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"id": "binding-1",
|
||||
"tenant_id": "tenant-1",
|
||||
"access_policy_id": "policy-1",
|
||||
"resource_type": "app",
|
||||
"resource_id": "app-1",
|
||||
"account_id": "acct-1",
|
||||
"account_name": "Alice",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1")
|
||||
|
||||
assert out.data[0].account_name == "Alice"
|
||||
|
||||
def test_app_replace_bindings(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": []}
|
||||
payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"])
|
||||
svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/apps/access-policy/bindings"
|
||||
assert call.params == {"app_id": "app-1", "policy_id": "policy-1"}
|
||||
assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]}
|
||||
|
||||
def test_dataset_replace_bindings(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": []}
|
||||
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
|
||||
svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/datasets/access-policy/bindings"
|
||||
assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"}
|
||||
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
|
||||
|
||||
|
||||
class TestWorkspaceAccess:
|
||||
def test_app_matrix(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"items": [], "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}}
|
||||
out = svc.RBACService.WorkspaceAccess.app_matrix(
|
||||
"tenant-1",
|
||||
options=svc.ListOption(page_number=2, results_per_page=20),
|
||||
)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/workspace/apps/access-policy"
|
||||
assert call.params == {"page_number": 2, "results_per_page": 20}
|
||||
assert out.pagination and out.pagination.current_page == 2
|
||||
|
||||
def test_dataset_matrix(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"items": []}
|
||||
svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/workspace/datasets/access-policy"
|
||||
assert call.params is None
|
||||
|
||||
def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"items": [
|
||||
{
|
||||
"policy": {
|
||||
"id": "policy-1",
|
||||
"resource_type": "app",
|
||||
"name": "Workspace App Access",
|
||||
},
|
||||
"roles": None,
|
||||
"accounts": None,
|
||||
}
|
||||
],
|
||||
"pagination": None,
|
||||
}
|
||||
|
||||
out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1")
|
||||
|
||||
assert out.items[0].roles == []
|
||||
assert out.items[0].accounts == []
|
||||
|
||||
def test_workspace_app_replace_bindings(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": []}
|
||||
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
|
||||
svc.RBACService.WorkspaceAccess.replace_app_bindings(
|
||||
"tenant-1", "acct-1", "policy-1", payload
|
||||
)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings"
|
||||
assert call.params == {"policy_id": "policy-1"}
|
||||
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
|
||||
|
||||
def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"data": []}
|
||||
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
|
||||
svc.RBACService.WorkspaceAccess.replace_dataset_bindings(
|
||||
"tenant-1", "acct-1", "policy-1", payload
|
||||
)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings"
|
||||
assert call.params == {"policy_id": "policy-1"}
|
||||
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
|
||||
|
||||
|
||||
class TestMyPermissions:
|
||||
def test_get_without_payload_uses_get(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"workspace": {"permission_keys": ["workspace.member.manage"]},
|
||||
"app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []},
|
||||
"dataset": {"default_permission_keys": [], "overrides": []},
|
||||
}
|
||||
|
||||
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
|
||||
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/my-permissions"
|
||||
assert call.json is None
|
||||
assert call.params is None
|
||||
assert out.workspace.permission_keys == ["workspace.member.manage"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "workspace_keys", "app_keys", "dataset_keys"),
|
||||
[
|
||||
(
|
||||
"owner",
|
||||
["workspace.member.manage", "workspace.role.manage"],
|
||||
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
|
||||
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
|
||||
),
|
||||
(
|
||||
"admin",
|
||||
["workspace.member.manage", "workspace.role.manage"],
|
||||
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
|
||||
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
|
||||
),
|
||||
(
|
||||
"editor",
|
||||
[],
|
||||
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
|
||||
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
|
||||
),
|
||||
(
|
||||
"normal",
|
||||
[],
|
||||
["app.acl.view_layout", "app.acl.test_and_run"],
|
||||
[],
|
||||
),
|
||||
(
|
||||
"dataset_operator",
|
||||
[],
|
||||
[],
|
||||
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_uses_legacy_role_permissions_when_rbac_disabled(
|
||||
self,
|
||||
mock_send: MagicMock,
|
||||
role: str,
|
||||
workspace_keys: list[str],
|
||||
app_keys: list[str],
|
||||
dataset_keys: list[str],
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.__enter__.return_value = mock_session
|
||||
mock_session.scalar.return_value = role
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
|
||||
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
|
||||
):
|
||||
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert out.workspace.permission_keys == workspace_keys
|
||||
assert out.app.default_permission_keys == app_keys
|
||||
assert out.dataset.default_permission_keys == dataset_keys
|
||||
assert out.app.overrides == []
|
||||
assert out.dataset.overrides == []
|
||||
|
||||
def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock):
|
||||
mock_session = MagicMock()
|
||||
mock_session.__enter__.return_value = mock_session
|
||||
mock_session.scalar.return_value = None
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
|
||||
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
|
||||
):
|
||||
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert out.workspace.permission_keys == []
|
||||
assert out.app.default_permission_keys == []
|
||||
assert out.dataset.default_permission_keys == []
|
||||
|
||||
def test_get_with_single_resource_filters(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"workspace": {"permission_keys": []},
|
||||
"app": {"default_permission_keys": [], "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}]},
|
||||
"dataset": {"default_permission_keys": [], "overrides": []},
|
||||
}
|
||||
|
||||
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
|
||||
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1")
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/my-permissions"
|
||||
assert call.params == {"app_id": "app-1"}
|
||||
assert out.app.overrides[0].resource_id == "app-1"
|
||||
|
||||
|
||||
class TestMemberRoles:
|
||||
def test_get(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"account_id": "acct-2",
|
||||
"roles": [
|
||||
{
|
||||
"id": "role-1",
|
||||
"type": "workspace",
|
||||
"name": "Member",
|
||||
}
|
||||
],
|
||||
}
|
||||
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/members/rbac-roles"
|
||||
assert call.params == {"account_id": "acct-2"}
|
||||
assert out.account_id == "acct-2"
|
||||
assert out.roles[0].name == "Member"
|
||||
|
||||
def test_replace(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"account_id": "acct-2", "roles": []}
|
||||
svc.RBACService.MemberRoles.replace(
|
||||
"tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"]
|
||||
)
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "PUT"
|
||||
assert call.endpoint == "/rbac/members/rbac-roles"
|
||||
assert call.params == {"account_id": "acct-2"}
|
||||
assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]}
|
||||
|
||||
def test_batch_get(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"account_id": "acct-2",
|
||||
"roles": [
|
||||
{"id": "role-1", "name": "Admin"},
|
||||
{"id": "role-2", "name": "Editor"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"account_id": "acct-3",
|
||||
"roles": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"])
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/members/rbac-roles/batch"
|
||||
assert call.json == {"account_ids": ["acct-2", "acct-3"]}
|
||||
assert out[0].account_id == "acct-2"
|
||||
assert len(out[0].roles) == 2
|
||||
|
||||
|
||||
class TestResourcePermissions:
|
||||
def test_app_permissions_batch_get(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]},
|
||||
{"resource_id": "app-2", "permission_keys": []},
|
||||
]
|
||||
}
|
||||
|
||||
out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"])
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/apps/permission-keys/batch"
|
||||
assert call.json == {"app_ids": ["app-1", "app-2"]}
|
||||
assert out == {
|
||||
"app-1": ["app.acl.view_layout", "app.acl.edit"],
|
||||
"app-2": [],
|
||||
}
|
||||
|
||||
def test_dataset_permissions_batch_get(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {
|
||||
"data": [
|
||||
{"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]},
|
||||
{"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]},
|
||||
]
|
||||
}
|
||||
|
||||
out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"])
|
||||
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "POST"
|
||||
assert call.endpoint == "/rbac/datasets/permission-keys/batch"
|
||||
assert call.json == {"dataset_ids": ["ds-1", "ds-2"]}
|
||||
assert out == {
|
||||
"ds-1": ["dataset.acl.readonly"],
|
||||
"ds-2": ["dataset.acl.edit"],
|
||||
}
|
||||
|
||||
|
||||
class TestListOption:
|
||||
def test_empty_produces_empty_params(self):
|
||||
assert svc.ListOption().to_params() == {}
|
||||
|
||||
def test_reverse_serialises_as_lowercase_bool(self):
|
||||
assert svc.ListOption(reverse=False).to_params()["reverse"] == "false"
|
||||
assert svc.ListOption(reverse=True).to_params()["reverse"] == "true"
|
||||
|
||||
def test_extra_overrides_merge(self):
|
||||
assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == {
|
||||
"page_number": 1,
|
||||
"resource_type": "app",
|
||||
}
|
||||
@ -56,7 +56,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
isLoading: false,
|
||||
|
||||
@ -64,7 +64,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
isLoading: false,
|
||||
|
||||
@ -129,7 +129,7 @@ vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
|
||||
}))
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import InstalledApp from '@/app/components/explore/installed-app'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
@ -19,7 +19,7 @@ vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import AppAccessConfigPage from '@/app/components/app/access-config'
|
||||
|
||||
export type AccessConfigPageProps = {
|
||||
params: Promise<{ locale: Locale, appId: string }>
|
||||
}
|
||||
|
||||
const AccessConfig = async (props: AccessConfigPageProps) => {
|
||||
const params = await props.params
|
||||
|
||||
const { appId } = params
|
||||
|
||||
return <AppAccessConfigPage appId={appId} />
|
||||
}
|
||||
|
||||
export default AccessConfig
|
||||
@ -12,8 +12,6 @@ import {
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
RiUserSettingsFill,
|
||||
RiUserSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import { useUnmount } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@ -102,15 +100,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: 'Access Config',
|
||||
href: `/app/${appId}/access-config`,
|
||||
icon: RiUserSettingsLine,
|
||||
selectedIcon: RiUserSettingsFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
]
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import DatasetAccessConfigPage from '@/app/components/datasets/access-config'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}
|
||||
|
||||
const AccessConfig = async (props: Props) => {
|
||||
const params = await props.params
|
||||
|
||||
const { datasetId } = params
|
||||
|
||||
return <DatasetAccessConfigPage datasetId={datasetId} />
|
||||
}
|
||||
|
||||
export default AccessConfig
|
||||
@ -9,8 +9,6 @@ import {
|
||||
RiFileTextLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
RiUserSettingsFill,
|
||||
RiUserSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@ -85,13 +83,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
selectedIcon: RiEqualizer2Fill,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Access Config',
|
||||
href: `/datasets/${datasetId}/access-config`,
|
||||
icon: RiUserSettingsLine,
|
||||
selectedIcon: RiUserSettingsFill,
|
||||
disabled: false,
|
||||
},
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={
|
||||
cn(
|
||||
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-[400px] flex-col">
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,10 +14,10 @@ export default function SignInLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
{/* <Header /> */}
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex justify-center md:w-[440px] lg:w-[600px]">
|
||||
<div className="flex w-full justify-center md:w-[440px] lg:w-[600px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,10 +31,10 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
{isLoggedIn
|
||||
? (
|
||||
<AppContextProvider>
|
||||
|
||||
@ -1,120 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useCallback, useState } from 'react'
|
||||
import AccessRulesEditor from '@/app/components/access-rules-editor'
|
||||
|
||||
export type AccessConfigModalProps = {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
initialRules: AccessRule[]
|
||||
/**
|
||||
* Optional override label for the primary action. Defaults to "Save".
|
||||
*/
|
||||
saveLabel?: string
|
||||
/**
|
||||
* Optional override label for the cancel action. Defaults to "Cancel".
|
||||
*/
|
||||
cancelLabel?: string
|
||||
onClose: () => void
|
||||
onSave?: (rules: AccessRule[]) => void
|
||||
}
|
||||
|
||||
type AccessConfigModalBodyProps = Omit<AccessConfigModalProps, 'open'>
|
||||
|
||||
const AccessConfigModalBody = ({
|
||||
title,
|
||||
description,
|
||||
initialRules,
|
||||
saveLabel = 'Save',
|
||||
cancelLabel = 'Cancel',
|
||||
onClose,
|
||||
onSave,
|
||||
}: AccessConfigModalBodyProps) => {
|
||||
const [rules, setRules] = useState<AccessRule[]>(initialRules)
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave?.(rules)
|
||||
onClose()
|
||||
}, [onClose, onSave, rules])
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="flex max-h-[85vh] w-[520px] 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">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
slotClassNames={{ viewport: 'px-6 overscroll-contain' }}
|
||||
>
|
||||
<AccessRulesEditor rules={rules} onRulesChange={setRules} />
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-divider-subtle px-6 py-4">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const AccessConfigModal = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
initialRules,
|
||||
saveLabel,
|
||||
cancelLabel,
|
||||
onClose,
|
||||
onSave,
|
||||
}: AccessConfigModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{open && (
|
||||
<AccessConfigModalBody
|
||||
title={title}
|
||||
description={description}
|
||||
initialRules={initialRules}
|
||||
saveLabel={saveLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessConfigModal
|
||||
@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AccessRule,
|
||||
AssignedRole,
|
||||
} from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
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'
|
||||
|
||||
export type AccessRulesEditorProps = {
|
||||
rules: AccessRule[]
|
||||
/**
|
||||
* Called whenever assigned roles/members are mutated. The editor is
|
||||
* controlled when this callback is provided, uncontrolled (with internal
|
||||
* state seeded from `rules`) otherwise.
|
||||
*/
|
||||
onRulesChange?: (rules: AccessRule[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AccessRulesEditor = ({
|
||||
rules: rulesProp,
|
||||
onRulesChange,
|
||||
className,
|
||||
}: AccessRulesEditorProps) => {
|
||||
const isControlled = typeof onRulesChange === 'function'
|
||||
const [internalRules, setInternalRules] = useState<AccessRule[]>(rulesProp)
|
||||
const rules = isControlled ? rulesProp : internalRules
|
||||
|
||||
const updateRules = useCallback(
|
||||
(updater: (prev: AccessRule[]) => AccessRule[]) => {
|
||||
if (isControlled) {
|
||||
onRulesChange(updater(rulesProp))
|
||||
return
|
||||
}
|
||||
setInternalRules(prev => updater(prev))
|
||||
},
|
||||
[isControlled, onRulesChange, rulesProp],
|
||||
)
|
||||
|
||||
const [addingRule, setAddingRule] = useState<AccessRule | null>(null)
|
||||
|
||||
const handleAddRole = useCallback((rule: AccessRule) => {
|
||||
setAddingRule(rule)
|
||||
}, [])
|
||||
|
||||
const handleCloseAddModal = useCallback(() => {
|
||||
setAddingRule(null)
|
||||
}, [])
|
||||
|
||||
const handleAddSubmit = useCallback(
|
||||
(_selection: { roleIds: string[], memberIds: string[] }) => {
|
||||
// TODO: wire up to API when backend is ready.
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleRemoveRole = useCallback(
|
||||
(target: AccessRule, role: AssignedRole) => {
|
||||
updateRules(prev =>
|
||||
prev.map(rule =>
|
||||
rule.id === target.id
|
||||
? {
|
||||
...rule,
|
||||
assignedRoles: rule.assignedRoles.filter(r => r.id !== role.id),
|
||||
}
|
||||
: rule,
|
||||
),
|
||||
)
|
||||
},
|
||||
[updateRules],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{rules.map((rule, index) => (
|
||||
<AccessRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
showMenu={false}
|
||||
onAddRole={handleAddRole}
|
||||
onRemoveRole={handleRemoveRole}
|
||||
className={cn(index > 0 && 'border-t border-divider-subtle')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{addingRule && (
|
||||
<AddRuleTargetsModal
|
||||
open
|
||||
ruleName={addingRule.name}
|
||||
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
|
||||
initialMemberIds={[]}
|
||||
onClose={handleCloseAddModal}
|
||||
onSubmit={handleAddSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessRulesEditor
|
||||
@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import AccessRulesEditor from '@/app/components/access-rules-editor'
|
||||
|
||||
// TODO: replace with the per-app access rules fetched from the access-rules
|
||||
// API once available. Mirrors the workspace-level App access rules catalog.
|
||||
const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'app-full-access',
|
||||
name: 'Full access',
|
||||
description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-edit',
|
||||
name: 'Can edit',
|
||||
description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.',
|
||||
assignedRoles: [
|
||||
{ id: 'app-editor', name: 'App Editor' },
|
||||
{ id: 'it-staff', name: 'IT Staff' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-view-and-use',
|
||||
name: 'Can view & use',
|
||||
description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.',
|
||||
assignedRoles: [
|
||||
{ id: 'tester', name: 'Tester' },
|
||||
{ id: 'ops-staff', name: 'Ops Staff' },
|
||||
{ id: 'member', name: 'Member' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-preview',
|
||||
name: 'Can preview',
|
||||
description: 'View the app in the list only. Cannot open the editor or use the app.',
|
||||
assignedRoles: [
|
||||
{ id: 'partner', name: 'Partner' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
]
|
||||
|
||||
type AppAccessConfigPageProps = {
|
||||
appId: string
|
||||
}
|
||||
|
||||
const AppAccessConfigPage = ({ appId: _appId }: AppAccessConfigPageProps) => {
|
||||
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={DEFAULT_APP_ACCESS_RULES} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppAccessConfigPage
|
||||
@ -37,7 +37,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
|
||||
@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
}))
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
|
||||
@ -6,7 +6,7 @@ import SpecificGroupsOrMembers from '../specific-groups-or-members'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
}))
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import { useCallback, 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 Checkbox from '../../base/checkbox'
|
||||
import Input from '../../base/input'
|
||||
|
||||
@ -9,7 +9,7 @@ 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'
|
||||
|
||||
@ -5,7 +5,7 @@ import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from
|
||||
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 Loading from '../../base/loading'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -26,7 +26,7 @@ import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
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'
|
||||
|
||||
@ -61,7 +61,7 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: () => ({
|
||||
data: mockAccessSubjects,
|
||||
}),
|
||||
|
||||
@ -16,7 +16,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
@ -101,7 +101,7 @@ vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: vi.fn(() => Promise.resolve({ installed_apps: [{ id: 'installed-1' }] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
isLoading: false,
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import type { App } from '@/types/app'
|
||||
import AccessConfigModal from '@/app/components/access-config-modal'
|
||||
|
||||
// TODO: replace with the per-app access rules fetched from the access-rules API
|
||||
// once available. The catalog mirrors the workspace-level App access rules and
|
||||
// adds app-specific rules that can only be assigned per-app.
|
||||
const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'app-full-access',
|
||||
name: 'Full access',
|
||||
description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-edit',
|
||||
name: 'Can edit',
|
||||
description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-view-and-use',
|
||||
name: 'Can view & use',
|
||||
description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-preview',
|
||||
name: 'Can preview',
|
||||
description: 'View the app in the list only. Cannot open the editor or use the app.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'app-can-optimize-prompt',
|
||||
name: 'Can optimize prompt',
|
||||
description: 'Dedicated prompt optimization access.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
]
|
||||
|
||||
export type AppAccessConfigModalProps = {
|
||||
open: boolean
|
||||
app: Pick<App, 'id' | 'name'>
|
||||
onClose: () => void
|
||||
onSave?: (rules: AccessRule[]) => void
|
||||
}
|
||||
|
||||
const AppAccessConfigModal = ({
|
||||
open,
|
||||
app: _app,
|
||||
onClose,
|
||||
onSave,
|
||||
}: AppAccessConfigModalProps) => {
|
||||
return (
|
||||
<AccessConfigModal
|
||||
open={open}
|
||||
title="App Access Config"
|
||||
description="Configure access levels for this specific app."
|
||||
initialRules={DEFAULT_APP_ACCESS_RULES}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppAccessConfigModal
|
||||
@ -41,7 +41,7 @@ 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'
|
||||
@ -68,9 +68,6 @@ const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/ds
|
||||
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
|
||||
ssr: false,
|
||||
})
|
||||
const AppAccessConfigModal = dynamic(() => import('@/app/components/apps/app-access-config-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type AppCardProps = {
|
||||
app: App
|
||||
@ -89,7 +86,6 @@ type AppCardOperationsMenuProps = {
|
||||
onSwitch: () => void
|
||||
onDelete: () => void
|
||||
onAccessControl: () => void
|
||||
onAccessConfig: () => void
|
||||
}
|
||||
|
||||
const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
|
||||
@ -103,7 +99,6 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onAccessControl,
|
||||
onAccessConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
@ -172,10 +167,6 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessConfig)}>
|
||||
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-3"
|
||||
@ -226,7 +217,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const [showAccessConfig, setShowAccessConfig] = useState(false)
|
||||
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
|
||||
@ -298,13 +288,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleShowAccessConfig = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowAccessConfig(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
@ -567,7 +550,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
onAccessConfig={handleShowAccessConfig}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
@ -582,7 +564,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessControl={handleShowAccessControl}
|
||||
onAccessConfig={handleShowAccessConfig}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@ -689,13 +670,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
|
||||
{showAccessControl && (
|
||||
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
|
||||
)}
|
||||
{showAccessConfig && (
|
||||
<AppAccessConfigModal
|
||||
open
|
||||
app={app}
|
||||
onClose={() => setShowAccessConfig(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import AccessRulesEditor from '@/app/components/access-rules-editor'
|
||||
|
||||
// TODO: replace with the per-knowledge-base access rules fetched from the
|
||||
// access-rules API once available. Mirrors the workspace-level Knowledge Base
|
||||
// access rules catalog.
|
||||
const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'kb-full-access',
|
||||
name: 'Full access',
|
||||
description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-edit',
|
||||
name: 'Can edit',
|
||||
description: 'Edit knowledge base content, modify settings, and run tests.',
|
||||
assignedRoles: [
|
||||
{ id: 'kb-editor', name: 'KB Editor' },
|
||||
{ id: 'ops-staff', name: 'Ops Staff' },
|
||||
{ id: 'it-staff', name: 'IT Staff' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-view-and-use',
|
||||
name: 'Can view & use',
|
||||
description: 'View knowledge base sources, configs, and logs. Cannot modify content.',
|
||||
assignedRoles: [
|
||||
{ id: 'member', name: 'Member' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-preview',
|
||||
name: 'Can preview',
|
||||
description: 'View in the list only. Cannot access the detail page.',
|
||||
assignedRoles: [
|
||||
{ id: 'partner', name: 'Partner' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-test',
|
||||
name: 'Can test',
|
||||
description: 'Test knowledge base retrieval efficiency in sandbox.',
|
||||
assignedRoles: [
|
||||
{ id: 'tester', name: 'Tester' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
]
|
||||
|
||||
type DatasetAccessConfigPageProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const DatasetAccessConfigPage = ({ datasetId: _datasetId }: DatasetAccessConfigPageProps) => {
|
||||
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={DEFAULT_KB_ACCESS_RULES} />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetAccessConfigPage
|
||||
@ -18,7 +18,6 @@ describe('Operations', () => {
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
openAccessConfig: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -81,14 +80,6 @@ describe('Operations', () => {
|
||||
fireEvent.click(screen.getByText(/operation\.delete/))
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call openAccessConfig when access config is clicked', () => {
|
||||
const openAccessConfig = vi.fn()
|
||||
renderInMenu(<Operations {...defaultProps} openAccessConfig={openAccessConfig} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Access Config'))
|
||||
expect(openAccessConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
@ -71,12 +71,10 @@ describe('DatasetCardModals', () => {
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
showAccessConfig: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
onCloseRename: vi.fn(),
|
||||
onCloseConfirm: vi.fn(),
|
||||
onCloseAccessConfig: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
@ -211,7 +209,6 @@ describe('DatasetCardModals', () => {
|
||||
modalState={{
|
||||
showRenameModal: true,
|
||||
showConfirmDelete: true,
|
||||
showAccessConfig: false,
|
||||
confirmMessage: 'Delete this dataset?',
|
||||
}}
|
||||
/>,
|
||||
|
||||
@ -34,7 +34,6 @@ describe('OperationsDropdown', () => {
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
openAccessConfig: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -10,17 +10,11 @@ import {
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import RenameDatasetModal from '../../../rename-modal'
|
||||
|
||||
const DatasetAccessConfigModal = dynamic(() => import('../dataset-access-config-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type ModalState = {
|
||||
showRenameModal: boolean
|
||||
showConfirmDelete: boolean
|
||||
showAccessConfig: boolean
|
||||
confirmMessage: string
|
||||
}
|
||||
|
||||
@ -29,7 +23,6 @@ type DatasetCardModalsProps = {
|
||||
modalState: ModalState
|
||||
onCloseRename: () => void
|
||||
onCloseConfirm: () => void
|
||||
onCloseAccessConfig: () => void
|
||||
onConfirmDelete: () => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
@ -39,7 +32,6 @@ const DatasetCardModals = ({
|
||||
modalState,
|
||||
onCloseRename,
|
||||
onCloseConfirm,
|
||||
onCloseAccessConfig,
|
||||
onConfirmDelete,
|
||||
onSuccess,
|
||||
}: DatasetCardModalsProps) => {
|
||||
@ -55,13 +47,6 @@ const DatasetCardModals = ({
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
{modalState.showAccessConfig && (
|
||||
<DatasetAccessConfigModal
|
||||
open
|
||||
dataset={dataset}
|
||||
onClose={onCloseAccessConfig}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog open={modalState.showConfirmDelete} onOpenChange={open => !open && onCloseConfirm()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
|
||||
@ -14,7 +14,6 @@ type OperationsDropdownProps = {
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: (include?: boolean) => void
|
||||
detectIsUsedByApp: () => void
|
||||
openAccessConfig: () => void
|
||||
}
|
||||
|
||||
const OperationsDropdown = ({
|
||||
@ -23,7 +22,6 @@ const OperationsDropdown = ({
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
openAccessConfig,
|
||||
}: OperationsDropdownProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
@ -60,7 +58,6 @@ const OperationsDropdown = ({
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
openAccessConfig={openAccessConfig}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import AccessConfigModal from '@/app/components/access-config-modal'
|
||||
|
||||
// TODO: replace with the per-knowledge-base access rules fetched from the
|
||||
// access-rules API once available. The catalog mirrors the workspace-level
|
||||
// Knowledge Base access rules.
|
||||
const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [
|
||||
{
|
||||
id: 'kb-full-access',
|
||||
name: 'Full access',
|
||||
description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-edit',
|
||||
name: 'Can edit',
|
||||
description: 'Edit knowledge base content, modify settings, and run tests.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-view-and-use',
|
||||
name: 'Can view & use',
|
||||
description: 'View knowledge base sources, configs, and logs. Cannot modify content.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-preview',
|
||||
name: 'Can preview',
|
||||
description: 'View in the list only. Cannot access the detail page.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
id: 'kb-can-test',
|
||||
name: 'Can test',
|
||||
description: 'Test knowledge base retrieval efficiency in sandbox.',
|
||||
assignedRoles: [
|
||||
{ id: 'owner', name: 'Owner' },
|
||||
{ id: 'admin', name: 'Admin' },
|
||||
{ id: 'marketing-lead', name: 'Marketing Lead' },
|
||||
{ id: 'kb-admin', name: 'KB Admin' },
|
||||
{ id: 'app-admin', name: 'App Admin' },
|
||||
{ id: 'executive', name: 'Executive' },
|
||||
],
|
||||
permissions: [],
|
||||
},
|
||||
]
|
||||
|
||||
export type DatasetAccessConfigModalProps = {
|
||||
open: boolean
|
||||
dataset: Pick<DataSet, 'id' | 'name'>
|
||||
onClose: () => void
|
||||
onSave?: (rules: AccessRule[]) => void
|
||||
}
|
||||
|
||||
const DatasetAccessConfigModal = ({
|
||||
open,
|
||||
dataset: _dataset,
|
||||
onClose,
|
||||
onSave,
|
||||
}: DatasetAccessConfigModalProps) => {
|
||||
return (
|
||||
<AccessConfigModal
|
||||
open={open}
|
||||
title="Knowledge Base Access Config"
|
||||
description="Configure access levels for this specific knowledge base."
|
||||
initialRules={DEFAULT_KB_ACCESS_RULES}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetAccessConfigModal
|
||||
@ -10,7 +10,6 @@ import { downloadBlob } from '@/utils/download'
|
||||
type ModalState = {
|
||||
showRenameModal: boolean
|
||||
showConfirmDelete: boolean
|
||||
showAccessConfig: boolean
|
||||
confirmMessage: string
|
||||
}
|
||||
|
||||
@ -31,7 +30,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
showAccessConfig: false,
|
||||
confirmMessage: '',
|
||||
})
|
||||
|
||||
@ -51,14 +49,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
setModalState(prev => ({ ...prev, showConfirmDelete: false }))
|
||||
}, [])
|
||||
|
||||
const openAccessConfig = useCallback(() => {
|
||||
setModalState(prev => ({ ...prev, showAccessConfig: true }))
|
||||
}, [])
|
||||
|
||||
const closeAccessConfig = useCallback(() => {
|
||||
setModalState(prev => ({ ...prev, showAccessConfig: false }))
|
||||
}, [])
|
||||
|
||||
// API mutations
|
||||
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
|
||||
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
|
||||
@ -132,8 +122,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
|
||||
openRenameModal,
|
||||
closeRenameModal,
|
||||
closeConfirmDelete,
|
||||
openAccessConfig,
|
||||
closeAccessConfig,
|
||||
|
||||
// Export state
|
||||
exporting,
|
||||
|
||||
@ -37,8 +37,6 @@ const DatasetCard = ({
|
||||
openRenameModal,
|
||||
closeRenameModal,
|
||||
closeConfirmDelete,
|
||||
openAccessConfig,
|
||||
closeAccessConfig,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
onConfirmDelete,
|
||||
@ -90,7 +88,6 @@ const DatasetCard = ({
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
openAccessConfig={openAccessConfig}
|
||||
/>
|
||||
</div>
|
||||
<DatasetCardModals
|
||||
@ -98,7 +95,6 @@ const DatasetCard = ({
|
||||
modalState={modalState}
|
||||
onCloseRename={closeRenameModal}
|
||||
onCloseConfirm={closeConfirmDelete}
|
||||
onCloseAccessConfig={closeAccessConfig}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
|
||||
@ -11,7 +11,6 @@ type OperationsProps = {
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: () => void
|
||||
detectIsUsedByApp: () => void
|
||||
openAccessConfig: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
@ -21,7 +20,6 @@ const Operations = ({
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
openAccessConfig,
|
||||
onClose,
|
||||
}: OperationsProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -41,32 +39,23 @@ const Operations = ({
|
||||
detectIsUsedByApp()
|
||||
}
|
||||
|
||||
const handleAccessConfig = () => {
|
||||
onClose?.()
|
||||
openAccessConfig()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" />
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
|
||||
@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import InstalledApp from '../index'
|
||||
@ -12,7 +12,7 @@ import InstalledApp from '../index'
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/access-control/use-app-access-control', () => ({
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
|
||||
@ -7,7 +7,7 @@ import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import TextGenerationApp from '@/app/components/share/text-generation'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicy } from '@/models/access-control'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { useCopyAccessRule, useDeleteAccessRule } from '@/service/access-control/use-workspace-access-rules'
|
||||
|
||||
export type AccessRuleRowMenuProps = {
|
||||
rule: AccessPolicy
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
const AccessRuleRowMenu = ({
|
||||
rule,
|
||||
onEdit,
|
||||
}: AccessRuleRowMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { mutateAsync: copyAccessRule } = useCopyAccessRule(rule.resource_type)
|
||||
const { mutateAsync: deleteAccessRule } = useDeleteAccessRule(rule.resource_type)
|
||||
|
||||
const handleCopyRules = useCallback(() => {
|
||||
copyAccessRule(rule.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule copied successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [copyAccessRule, rule.id])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteAccessRule(rule.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule deleted successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [deleteAccessRule, rule.id])
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={open ? 'bg-state-base-hover' : ''}
|
||||
aria-label="More actions"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[140px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="system-sm-semibold text-text-secondary"
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="system-sm-semibold text-text-secondary"
|
||||
onClick={handleCopyRules}
|
||||
>
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="system-sm-semibold"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessRuleRowMenu
|
||||
@ -1,115 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyWithBindings, BindingType } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { memo, useCallback } from 'react'
|
||||
import {
|
||||
useUpdateAppAccessRuleBindings,
|
||||
useUpdateDatasetAccessRuleBindings,
|
||||
} from '@/service/access-control/use-workspace-access-rules'
|
||||
import AccessRuleRowMenu from './access-rule-row-menu'
|
||||
import RoleTag from './role-tag'
|
||||
|
||||
export type AccessRuleRowProps = {
|
||||
rule: AccessPolicyWithBindings
|
||||
className?: string
|
||||
showMenu?: boolean
|
||||
onEdit?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
}
|
||||
|
||||
const AccessRuleRow = ({
|
||||
rule,
|
||||
className,
|
||||
showMenu = true,
|
||||
onEdit,
|
||||
onAddRole,
|
||||
}: AccessRuleRowProps) => {
|
||||
const { policy, role_ids, account_ids } = rule
|
||||
const { id: policyId, resource_type } = policy
|
||||
|
||||
const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule])
|
||||
const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule])
|
||||
|
||||
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
|
||||
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
|
||||
|
||||
const handleRemoveRole = useCallback((id: string, type: BindingType) => {
|
||||
const payload = {
|
||||
id: policyId,
|
||||
role_ids: role_ids.map(role => role.id),
|
||||
account_ids: account_ids.map(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)
|
||||
}
|
||||
if (resource_type === 'app') {
|
||||
updateAppAccessRuleBindings(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (resource_type === 'dataset') {
|
||||
updateDatasetAccessRuleBindings(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule updated successfully')
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [account_ids, policyId, resource_type, role_ids, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings])
|
||||
|
||||
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">
|
||||
{role_ids.map(role => (
|
||||
<RoleTag
|
||||
key={role.id}
|
||||
id={role.id}
|
||||
label={role.name}
|
||||
type="role"
|
||||
onRemove={handleRemoveRole}
|
||||
/>
|
||||
))}
|
||||
{account_ids.map(account => (
|
||||
<RoleTag
|
||||
key={account.id}
|
||||
id={account.id}
|
||||
label={account.name}
|
||||
type="account"
|
||||
onRemove={handleRemoveRole}
|
||||
/>
|
||||
))}
|
||||
<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 && (
|
||||
<AccessRuleRowMenu
|
||||
onEdit={handleEdit}
|
||||
rule={policy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AccessRuleRow)
|
||||
@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyWithBindings } from '@/models/access-control'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
import AccessRuleRow from './access-rule-row'
|
||||
|
||||
type AccessRuleSectionProps = {
|
||||
title: string
|
||||
rules: AccessPolicyWithBindings[]
|
||||
createButtonLabel: string
|
||||
onCreate?: () => void
|
||||
onEditRule?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AccessRuleSection = ({
|
||||
title,
|
||||
rules,
|
||||
createButtonLabel,
|
||||
onCreate,
|
||||
onEditRule,
|
||||
onAddRole,
|
||||
className,
|
||||
}: AccessRuleSectionProps) => {
|
||||
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>
|
||||
<Button variant="secondary" size="medium" onClick={onCreate}>
|
||||
{createButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
{rules.map((rule, index) => (
|
||||
<AccessRuleRow
|
||||
key={rule.policy.id}
|
||||
rule={rule}
|
||||
className={cn(index > 0 && 'border-t border-divider-subtle')}
|
||||
onEdit={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AccessRuleSection)
|
||||
@ -1,354 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
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 { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
export type AssignableMemberOption = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
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 toMemberOption = (member: Member): AssignableMemberOption => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatarUrl: member.avatar_url ?? member.avatar ?? null,
|
||||
})
|
||||
|
||||
const AddRuleTargetsModalBody = ({
|
||||
ruleName,
|
||||
initialRoleIds = [],
|
||||
initialMemberIds = [],
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddRuleTargetsModalBaseProps) => {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('roles')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
|
||||
|
||||
const { data: rolesData, isLoading: rolesLoading } = useWorkspaceRoleList({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const roles = useMemo(() => rolesData?.data ?? [], [rolesData])
|
||||
|
||||
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 trimmed = keyword.trim().toLowerCase()
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
if (!trimmed)
|
||||
return roles
|
||||
return roles.filter(
|
||||
role =>
|
||||
role.name.toLowerCase().includes(trimmed)
|
||||
|| role.description?.toLowerCase().includes(trimmed),
|
||||
)
|
||||
}, [roles, trimmed])
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!trimmed)
|
||||
return members
|
||||
return members.filter(
|
||||
member =>
|
||||
member.name.toLowerCase().includes(trimmed)
|
||||
|| member.email.toLowerCase().includes(trimmed),
|
||||
)
|
||||
}, [members, trimmed])
|
||||
|
||||
const handleSwitchTab = useCallback((tab: TabKey) => {
|
||||
setActiveTab(tab)
|
||||
setKeyword('')
|
||||
}, [])
|
||||
|
||||
const toggleRole = useCallback((id: string) => {
|
||||
setSelectedRoleIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const toggleMember = useCallback((id: string) => {
|
||||
setSelectedMemberIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
)
|
||||
}, [])
|
||||
|
||||
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-[528px] w-[480px] 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={() => handleSwitchTab(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>
|
||||
|
||||
<div className="shrink-0 px-6 pt-3 pb-2">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onClear={() => setKeyword('')}
|
||||
placeholder={
|
||||
activeTab === 'roles' ? 'Search roles...' : 'Search members...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
|
||||
>
|
||||
{activeTab === 'roles' && (
|
||||
rolesLoading
|
||||
? (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
Loading roles...
|
||||
</div>
|
||||
)
|
||||
: filteredRoles.length === 0
|
||||
? (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
No matching roles
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ul className="flex flex-col gap-0.5 pb-2">
|
||||
{filteredRoles.map((role) => {
|
||||
const checked = selectedRoleIds.includes(role.id)
|
||||
const handleToggle = () => toggleRole(role.id)
|
||||
return (
|
||||
<li key={role.id}>
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start 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 mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{role.name}
|
||||
</div>
|
||||
<div className="mt-0.5 system-xs-regular text-text-tertiary">
|
||||
{role.description || 'No description'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'members' && (
|
||||
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>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{summary}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const AddRuleTargetsModal = ({
|
||||
ruleName,
|
||||
initialRoleIds,
|
||||
initialMemberIds,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddRuleTargetsModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<AddRuleTargetsModalBody
|
||||
ruleName={ruleName}
|
||||
initialRoleIds={initialRoleIds}
|
||||
initialMemberIds={initialMemberIds}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRuleTargetsModal
|
||||
@ -1,38 +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 } = useWorkspaceAppAccessRules({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const appAccessRules = appAccessRulesResponse?.items || []
|
||||
|
||||
return (
|
||||
<AccessRuleSection
|
||||
title="App Access Rules"
|
||||
rules={appAccessRules}
|
||||
createButtonLabel="Create App permission set"
|
||||
onCreate={onCreate}
|
||||
onEditRule={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppAccessRuleSection
|
||||
@ -1,38 +0,0 @@
|
||||
import type { AccessPolicyWithBindings } from '@/models/access-control'
|
||||
import { useWorkspaceDatasetAccessRules } from '@/service/access-control/use-workspace-access-rules'
|
||||
import AccessRuleSection from './access-rule-section'
|
||||
|
||||
type DatasetAccessRuleSectionProps = {
|
||||
className?: string
|
||||
onCreate?: () => void
|
||||
onEditRule?: (rule: AccessPolicyWithBindings) => void
|
||||
onAddRole?: (rule: AccessPolicyWithBindings) => void
|
||||
}
|
||||
|
||||
const DatasetAccessRuleSection = ({
|
||||
className,
|
||||
onCreate,
|
||||
onEditRule,
|
||||
onAddRole,
|
||||
}: DatasetAccessRuleSectionProps) => {
|
||||
const { data: datasetAccessRulesResponse } = useWorkspaceDatasetAccessRules({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
const datasetAccessRules = datasetAccessRulesResponse?.items || []
|
||||
|
||||
return (
|
||||
<AccessRuleSection
|
||||
title="Knowledge Base Access Rules"
|
||||
rules={datasetAccessRules}
|
||||
createButtonLabel="Create KB permission set"
|
||||
onCreate={onCreate}
|
||||
onEditRule={onEditRule}
|
||||
onAddRole={onAddRole}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetAccessRuleSection
|
||||
@ -1,154 +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, 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
|
||||
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,
|
||||
initialValues: {
|
||||
name: policy.name,
|
||||
description: policy.description,
|
||||
permissionKeys: policy.permission_keys,
|
||||
},
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const { mutateAsync: createAccessRule } = useCreateAccessRule(permissionSetModalState?.resourceType)
|
||||
|
||||
const handlePermissionSetSubmit = useCallback(
|
||||
(values: PermissionSetFormValues) => {
|
||||
const { name, description, permissionKeys } = values
|
||||
createAccessRule({
|
||||
name,
|
||||
description,
|
||||
permission_keys: permissionKeys,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Access rule created successfully')
|
||||
closePermissionSetModal()
|
||||
},
|
||||
})
|
||||
},
|
||||
[closePermissionSetModal, createAccessRule],
|
||||
)
|
||||
|
||||
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.role_ids.map(role => role.id)}
|
||||
initialMemberIds={addingRule.account_ids.map(account => account.id)}
|
||||
onClose={closeAddModal}
|
||||
onSubmit={handleAddSubmit}
|
||||
/>
|
||||
)}
|
||||
{permissionSetModalState && (
|
||||
<PermissionSetModal
|
||||
open
|
||||
mode={permissionSetModalState.mode}
|
||||
resourceType={permissionSetModalState.resourceType}
|
||||
initialValues={permissionSetModalState.initialValues}
|
||||
onClose={closePermissionSetModal}
|
||||
onSubmit={handlePermissionSetSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessRulesPage
|
||||
@ -1,22 +0,0 @@
|
||||
import type { AccessPolicyResourceType } from '@/models/access-control'
|
||||
import { useAppPermissionCatalog, useDatasetPermissionCatalog } from '@/service/access-control/use-permission-catalog'
|
||||
|
||||
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
|
||||
const { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
|
||||
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
|
||||
|
||||
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
|
||||
|
||||
const groups = permissionCatalog?.groups || []
|
||||
|
||||
const allPermissions = groups.flatMap(g => g.permissions) || []
|
||||
|
||||
const permissionMap = Object.fromEntries(
|
||||
allPermissions.map(p => [p.key, p]),
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
permissionMap,
|
||||
}
|
||||
}
|
||||
@ -1,225 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyResourceType } from '@/models/access-control'
|
||||
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 { useState } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { usePermissionsGroups } from './hooks'
|
||||
import PermissionPicker from './permission-picker'
|
||||
|
||||
export type PermissionSetModalMode = 'create' | 'edit'
|
||||
|
||||
export type PermissionSetFormValues = {
|
||||
name: string
|
||||
description: string
|
||||
permissionKeys: string[]
|
||||
}
|
||||
|
||||
export type PermissionSetModalProps = {
|
||||
open: boolean
|
||||
mode: PermissionSetModalMode
|
||||
resourceType: AccessPolicyResourceType
|
||||
initialValues?: Partial<PermissionSetFormValues>
|
||||
onClose: () => void
|
||||
onSubmit: (values: PermissionSetFormValues) => void
|
||||
}
|
||||
|
||||
const RESOURCE_LABEL: Record<AccessPolicyResourceType, string> = {
|
||||
app: 'App',
|
||||
dataset: 'Knowledge Base',
|
||||
}
|
||||
|
||||
const buildTitle = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
|
||||
const verb = mode === 'create' ? 'Create' : 'Edit'
|
||||
return `${verb} ${RESOURCE_LABEL[resource]} permission set`
|
||||
}
|
||||
|
||||
const buildDescription = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
|
||||
if (mode === 'edit')
|
||||
return 'Modify the name, description, and permissions granted for this permission set.'
|
||||
if (resource === 'app')
|
||||
return 'Create an app permission set that can be referenced in access rules for quick authorization.'
|
||||
return 'Create a knowledge base permission set that can be referenced in access rules for quick authorization.'
|
||||
}
|
||||
|
||||
type PermissionSetModalBodyProps = Omit<PermissionSetModalProps, 'open'>
|
||||
|
||||
const PermissionSetModalBody = ({
|
||||
mode,
|
||||
resourceType,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PermissionSetModalBodyProps) => {
|
||||
const [name, setName] = useState(initialValues?.name ?? '')
|
||||
const [description, setDescription] = useState(initialValues?.description ?? '')
|
||||
const [permissionKeys, setPermissionKeys] = useState<string[]>(initialValues?.permissionKeys ?? [])
|
||||
|
||||
const { permissionMap } = usePermissionsGroups(resourceType)
|
||||
|
||||
const trimmedName = name.trim()
|
||||
const canSubmit = trimmedName.length > 0
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canSubmit)
|
||||
return
|
||||
onSubmit({
|
||||
name: trimmedName,
|
||||
description: description.trim(),
|
||||
permissionKeys,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleRemovePermission = (key: string) => {
|
||||
setPermissionKeys(prev => prev.filter(p => p !== key))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="max-h-[85vh] w-[560px] overflow-visible p-0"
|
||||
backdropProps={{ forceRender: true }}
|
||||
>
|
||||
<div className="relative px-6 pt-6 pb-4">
|
||||
<DialogCloseButton />
|
||||
<div className="pr-8">
|
||||
<DialogTitle className="system-xl-semibold text-text-primary">
|
||||
{buildTitle(mode, resourceType)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{buildDescription(mode, resourceType)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-divider-subtle" />
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="permission-set-name" className="system-sm-medium text-text-secondary">
|
||||
permission set name
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="permission-set-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Can export DSL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="permission-set-description" className="system-sm-medium text-text-secondary">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="permission-set-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe what this permission set grants"
|
||||
className="min-h-20 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="system-sm-medium text-text-secondary">Permissions</div>
|
||||
{permissionKeys.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{permissionKeys.map((key) => {
|
||||
const p = permissionMap[key]
|
||||
if (!p)
|
||||
return null
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md bg-util-colors-indigo-indigo-50 px-1.5 py-0.5 system-xs-medium text-text-accent',
|
||||
'border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-3.5 w-3.5 items-center justify-center rounded hover:bg-state-base-hover"
|
||||
aria-label={`Remove ${p.name}`}
|
||||
onClick={() => handleRemovePermission(key)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<PermissionPicker
|
||||
resourceType={resourceType}
|
||||
value={permissionKeys}
|
||||
onChange={setPermissionKeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<a
|
||||
href="https://docs.dify.ai/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
<span>Learn more about permissions</span>
|
||||
<span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" />
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const PermissionSetModal = ({
|
||||
open,
|
||||
mode,
|
||||
resourceType,
|
||||
initialValues,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: PermissionSetModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<PermissionSetModalBody
|
||||
mode={mode}
|
||||
resourceType={resourceType}
|
||||
initialValues={initialValues}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionSetModal
|
||||
@ -1,178 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPolicyResourceType, PermissionGroup } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { usePermissionsGroups } from './hooks'
|
||||
|
||||
type PermissionPickerProps = {
|
||||
resourceType: AccessPolicyResourceType
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionPicker = ({
|
||||
resourceType,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: PermissionPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Re-focus the search input after the dropdown takes over focus, so the user
|
||||
// can keep typing to filter permissions.
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
const { groups } = usePermissionsGroups(resourceType)
|
||||
|
||||
const filteredGroups = useMemo<PermissionGroup[]>(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q)
|
||||
return groups
|
||||
return groups
|
||||
.map(group => ({
|
||||
...group,
|
||||
permissions: group.permissions.filter(i => i.name.toLowerCase().includes(q)),
|
||||
}))
|
||||
.filter(group => group.permissions.length > 0)
|
||||
}, [search, groups])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value])
|
||||
|
||||
const togglePermission = (id: string) => {
|
||||
if (selectedSet.has(id))
|
||||
onChange(value.filter(v => v !== id))
|
||||
else
|
||||
onChange([...value, id])
|
||||
}
|
||||
|
||||
const getGroupState = (group: PermissionGroup) => {
|
||||
const checkedCount = group.permissions.reduce(
|
||||
(acc, i) => acc + (selectedSet.has(i.key) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
return {
|
||||
allChecked: checkedCount > 0 && checkedCount === group.permissions.length,
|
||||
indeterminate: checkedCount > 0 && checkedCount < group.permissions.length,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGroup = (group: PermissionGroup) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
const ids = group.permissions.map(i => i.key)
|
||||
if (allChecked || indeterminate) {
|
||||
const idSet = new Set(ids)
|
||||
onChange(value.filter(v => !idSet.has(v)))
|
||||
}
|
||||
else {
|
||||
const next = new Set(value)
|
||||
ids.forEach(id => next.add(id))
|
||||
onChange(Array.from(next))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-text items-center gap-2 rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-components-input-bg-hover',
|
||||
open && 'bg-components-input-bg-active shadow-xs ring-[0.5px] ring-components-input-border-active',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 grow appearance-none bg-transparent system-sm-regular text-text-primary caret-primary-600 outline-hidden placeholder:text-text-tertiary"
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape')
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="max-h-80 w-[var(--anchor-width)] py-1"
|
||||
>
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
No permissions found
|
||||
</div>
|
||||
)}
|
||||
{filteredGroups.map((group) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
return (
|
||||
<DropdownMenuGroup key={group.group_key}>
|
||||
<button
|
||||
type="button"
|
||||
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{group.group_name}
|
||||
</span>
|
||||
</button>
|
||||
{group.permissions.map((item) => {
|
||||
const checked = selectedSet.has(item.key)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.key}
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePermission(item.key)}
|
||||
className="gap-2 pl-6"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{item.name}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionPicker
|
||||
@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { BindingType } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
|
||||
export type RoleTagProps = {
|
||||
id: string
|
||||
label: string
|
||||
type: BindingType
|
||||
onRemove?: (id: string, type: BindingType) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RoleTag = ({
|
||||
id,
|
||||
label,
|
||||
type,
|
||||
onRemove,
|
||||
className,
|
||||
}: RoleTagProps) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-full items-center gap-0.5 rounded-md bg-components-badge-bg-gray-soft px-1.5 system-xs-medium text-text-secondary shadow-xs',
|
||||
className,
|
||||
)}
|
||||
data-testid="access-rule-role-tag"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(id, type)
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center rounded text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RoleTag)
|
||||
@ -3,8 +3,6 @@ export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
|
||||
export const ACCOUNT_SETTING_TAB = {
|
||||
PROVIDER: 'provider',
|
||||
MEMBERS: 'members',
|
||||
PERMISSIONS: 'permissions',
|
||||
ACCESS_RULES: 'access-rules',
|
||||
BILLING: 'billing',
|
||||
DATA_SOURCE: 'data-source',
|
||||
API_BASED_EXTENSION: 'api-based-extension',
|
||||
|
||||
@ -16,14 +16,12 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import AccessRulesPage from './access-rules-page'
|
||||
import ApiBasedExtensionPage from './api-based-extension-page'
|
||||
import DataSourcePage from './data-source-page-new'
|
||||
import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
import ModelProviderPage from './model-provider-page'
|
||||
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
|
||||
import PermissionsPage from './permissions-page'
|
||||
|
||||
const iconClassName = `
|
||||
w-5 h-5 mr-2
|
||||
@ -51,7 +49,7 @@ export default function AccountSetting({
|
||||
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
|
||||
const activeMenu = activeTab
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, enableReplaceWebAppLogo, enableAccessControl } = useProviderContext()
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
const workplaceGroupItems: GroupItem[] = (() => {
|
||||
@ -73,23 +71,6 @@ export default function AccountSetting({
|
||||
},
|
||||
]
|
||||
|
||||
if (enableAccessControl) {
|
||||
items.push(
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.PERMISSIONS,
|
||||
name: t('settings.permissions', { ns: 'common' }),
|
||||
icon: <span className={cn('i-ri-user-settings-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-user-settings-fill', iconClassName)} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.ACCESS_RULES,
|
||||
name: t('settings.accessRules', { ns: 'common' }),
|
||||
icon: <span className={cn('i-ri-shield-user-line', iconClassName)} />,
|
||||
activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (enableBilling) {
|
||||
items.push({
|
||||
key: ACCOUNT_SETTING_TAB.BILLING,
|
||||
@ -247,8 +228,6 @@ export default function AccountSetting({
|
||||
<div className="px-4 pt-2 sm:px-8">
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && <ModelProviderPage searchText={searchValue} />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSIONS && <PermissionsPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_RULES && <AccessRulesPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
|
||||
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
|
||||
|
||||
@ -51,19 +51,11 @@ vi.mock('../invited-modal', () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../role-badges', () => ({
|
||||
default: ({ roles }: { roles: string[] }) => (
|
||||
<div data-testid="role-badges">{roles.join(',')}</div>
|
||||
),
|
||||
vi.mock('../operation', () => ({
|
||||
default: () => <div>Member Operation</div>,
|
||||
}))
|
||||
vi.mock('../member-menu', () => ({
|
||||
default: ({ member, onTransferOwnership, canTransferOwnership }: { member: Member, onTransferOwnership?: () => void, canTransferOwnership?: boolean }) => (
|
||||
<div data-testid="member-menu">
|
||||
{canTransferOwnership && member.role === 'owner' && onTransferOwnership && (
|
||||
<button onClick={onTransferOwnership}>Transfer ownership</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
vi.mock('../operation/transfer-ownership', () => ({
|
||||
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
|
||||
}))
|
||||
vi.mock('../transfer-ownership-modal', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
@ -73,16 +65,6 @@ vi.mock('../transfer-ownership-modal', () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../member-details-modal', () => ({
|
||||
default: ({ member, onClose, canAssignRoles }: { member: Member, onClose: () => void, canAssignRoles?: boolean }) => (
|
||||
<div>
|
||||
<div>Member Details Modal</div>
|
||||
<div data-testid="details-member-name">{member.name}</div>
|
||||
<div data-testid="details-can-assign">{String(canAssignRoles)}</div>
|
||||
<button onClick={onClose}>Close Member Details Modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/billing/upgrade-btn', () => ({
|
||||
default: () => <div>Upgrade Button</div>,
|
||||
}))
|
||||
@ -378,52 +360,6 @@ describe('MembersPage', () => {
|
||||
expect(screen.getByText('common.members.normal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open member details modal when a member row is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-2'))
|
||||
|
||||
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('details-member-name'))!.toHaveTextContent('Admin User')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close Member Details Modal' }))
|
||||
expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open member details modal via keyboard Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
const row = screen.getByTestId('member-row-2')
|
||||
row.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not allow assigning roles from member details when target is owner', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-1'))
|
||||
|
||||
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should not open member details when clicking the member menu area', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
|
||||
|
||||
expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show upgrade button when member limit is full', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||
enableBilling: true,
|
||||
|
||||
@ -1,218 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Member } from '@/models/common'
|
||||
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 { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
export type AssignableRole = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type AssignRolesModalProps = {
|
||||
open: boolean
|
||||
member: Member
|
||||
onClose: () => void
|
||||
onSubmit: (roleIds: string[]) => void
|
||||
}
|
||||
|
||||
type AssignRolesModalBodyProps = {
|
||||
roles: AssignableRole[]
|
||||
} & Omit<AssignRolesModalProps, 'open'>
|
||||
|
||||
// TODO: replace with roles fetched from the permissions API once available.
|
||||
const MOCK_ASSIGNABLE_ROLES: AssignableRole[] = [
|
||||
{ id: 'admin', name: 'Admin', description: 'Full access to workspace management and settings' },
|
||||
{ id: 'editor', name: 'Editor', description: 'Create and edit resources without settings access' },
|
||||
{ id: 'member', name: 'Member', description: 'Basic workspace access' },
|
||||
{ id: 'auditor', name: 'Auditor', description: 'View application logs and audit trails' },
|
||||
{ id: 'tester', name: 'Tester', description: 'Test applications in sandbox environments' },
|
||||
]
|
||||
|
||||
const AssignRolesModalBody = ({
|
||||
roles,
|
||||
member,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AssignRolesModalBodyProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [selected, setSelected] = useState<string[]>(() => {
|
||||
const match = MOCK_ASSIGNABLE_ROLES.find(r => r.id === member.role)
|
||||
return match ? [match.id] : []
|
||||
})
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
const trimmed = keyword.trim().toLowerCase()
|
||||
if (!trimmed)
|
||||
return roles
|
||||
return roles.filter(
|
||||
role =>
|
||||
role.name.toLowerCase().includes(trimmed)
|
||||
|| role.description?.toLowerCase().includes(trimmed),
|
||||
)
|
||||
}, [roles, keyword])
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSubmit(selected)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className="flex h-[484px] w-[480px] 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">
|
||||
{t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('members.assignRolesModal.description', {
|
||||
ns: 'common',
|
||||
defaultValue:
|
||||
'Select roles to assign to this member. All permissions from selected roles will be combined.',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-6">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onClear={() => setKeyword('')}
|
||||
placeholder={t('members.assignRolesModal.searchPlaceholder', {
|
||||
ns: 'common',
|
||||
defaultValue: 'Search roles...',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="mt-2 min-h-0 flex-1"
|
||||
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
|
||||
>
|
||||
{filteredRoles.length === 0
|
||||
? (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
{t('members.assignRolesModal.empty', {
|
||||
ns: 'common',
|
||||
defaultValue: 'No matching roles',
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{filteredRoles.map((role) => {
|
||||
const checked = selected.includes(role.id)
|
||||
const handleToggle = () => toggle(role.id)
|
||||
return (
|
||||
<li key={role.id}>
|
||||
<div
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start 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 mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{role.name}
|
||||
</div>
|
||||
{role.description && (
|
||||
<div className="mt-0.5 system-xs-regular text-text-tertiary">
|
||||
{role.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<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">
|
||||
{t('members.assignRolesModal.selectedCount', {
|
||||
ns: 'common',
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
const AssignRolesModal = ({
|
||||
open,
|
||||
member,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AssignRolesModalProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<AssignRolesModalBody
|
||||
roles={MOCK_ASSIGNABLE_ROLES}
|
||||
member={member}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssignRolesModal
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { InvitationResult, Member } from '@/models/common'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
@ -11,6 +11,7 @@ import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
@ -18,8 +19,8 @@ import EditWorkspaceModal from './edit-workspace-modal'
|
||||
import InviteButton from './invite-button'
|
||||
import InviteModal from './invite-modal'
|
||||
import InvitedModal from './invited-modal'
|
||||
import MemberDetailsModal from './member-details-modal'
|
||||
import MemberRow from './member-row'
|
||||
import Operation from './operation'
|
||||
import TransferOwnership from './operation/transfer-ownership'
|
||||
import TransferOwnershipModal from './transfer-ownership-modal'
|
||||
|
||||
const MembersPage = () => {
|
||||
@ -36,6 +37,7 @@ const MembersPage = () => {
|
||||
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
|
||||
const { data, refetch } = useMembers()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false)
|
||||
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
|
||||
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
|
||||
@ -45,30 +47,13 @@ const MembersPage = () => {
|
||||
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
|
||||
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
|
||||
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
|
||||
const [detailsMember, setDetailsMember] = useState<Member | null>(null)
|
||||
|
||||
const handleAssignRolesSubmit = (_roleIds: string[]) => {
|
||||
// TODO: wire to backend once multi-role member endpoint is ready.
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
refetch()
|
||||
}
|
||||
|
||||
const handleOpenDetails = useCallback((member: Member) => {
|
||||
setDetailsMember(member)
|
||||
}, [])
|
||||
|
||||
const handleTransferOwnership = useCallback(() => {
|
||||
setShowTransferOwnershipModal(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-subtle bg-linear-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[20px]">
|
||||
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
|
||||
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
|
||||
</span>
|
||||
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="flex items-center gap-1 system-md-semibold text-text-secondary">
|
||||
@ -133,24 +118,42 @@ const MembersPage = () => {
|
||||
<div className="overflow-visible lg:overflow-visible">
|
||||
<div className="flex min-w-[480px] items-center border-b border-divider-regular py-[7px]">
|
||||
<div className="grow px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.name', { ns: 'common' })}</div>
|
||||
<div className="w-[120px] shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
|
||||
<div className="w-[215px] shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
|
||||
<div className="w-[104px] shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
|
||||
<div className="w-[96px] shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
|
||||
</div>
|
||||
<div className="relative min-w-[480px]">
|
||||
{accounts.map(account => (
|
||||
<MemberRow
|
||||
key={account.id}
|
||||
member={account}
|
||||
roleLabel={RoleMap[account.role] || RoleMap.normal}
|
||||
isCurrentUser={userProfile.email === account.email}
|
||||
canManage={isCurrentWorkspaceManager}
|
||||
operatorRole={currentWorkspace.role}
|
||||
canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onOperate={refetch}
|
||||
onTransferOwnership={handleTransferOwnership}
|
||||
/>
|
||||
))}
|
||||
{
|
||||
accounts.map(account => (
|
||||
<div key={account.id} className="flex border-b border-divider-subtle">
|
||||
<div className="flex grow items-center px-3 py-2">
|
||||
<Avatar avatar={account.avatar_url} size="sm" className="mr-2" name={account.name} />
|
||||
<div className="">
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{account.name}
|
||||
{account.status === 'pending' && <span className="ml-1 system-xs-medium text-text-warning">{t('members.pending', { ns: 'common' })}</span>}
|
||||
{userProfile.email === account.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{account.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[104px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
|
||||
<div className="flex w-[96px] shrink-0 items-center">
|
||||
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
|
||||
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
|
||||
)}
|
||||
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
|
||||
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
|
||||
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
|
||||
)}
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -188,19 +191,6 @@ const MembersPage = () => {
|
||||
onClose={() => setShowTransferOwnershipModal(false)}
|
||||
/>
|
||||
)}
|
||||
{detailsMember && (
|
||||
<MemberDetailsModal
|
||||
open={!!detailsMember}
|
||||
member={detailsMember}
|
||||
roleLabel={RoleMap[detailsMember.role] || RoleMap.normal}
|
||||
canAssignRoles={
|
||||
isCurrentWorkspaceManager
|
||||
&& detailsMember.role !== 'owner'
|
||||
}
|
||||
onClose={() => setDetailsMember(null)}
|
||||
onAssignSubmit={handleAssignRolesSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AssignRolesModal from '../assign-roles-modal'
|
||||
import PermissionRoleChip from './permission-role-chip'
|
||||
|
||||
export type MemberDetailsModalProps = {
|
||||
open: boolean
|
||||
member: Member
|
||||
roleLabel: string
|
||||
canAssignRoles?: boolean
|
||||
onClose: () => void
|
||||
onAssignSubmit?: (roleIds: string[]) => void
|
||||
}
|
||||
|
||||
const MemberDetailsModal = ({
|
||||
open,
|
||||
member,
|
||||
roleLabel,
|
||||
canAssignRoles = false,
|
||||
onClose,
|
||||
onAssignSubmit,
|
||||
}: MemberDetailsModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
|
||||
const assignedRoles = [{ key: member.role, label: roleLabel }]
|
||||
|
||||
const handleAssignSubmit = (ids: string[]) => {
|
||||
onAssignSubmit?.(ids)
|
||||
setAssignOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[440px] overflow-visible p-0" backdropProps={{ forceRender: true }}>
|
||||
<div className="relative px-6 pt-6 pb-5">
|
||||
<DialogCloseButton />
|
||||
<DialogTitle className="pr-8 system-xl-semibold text-text-primary">
|
||||
{t('members.memberDetails.title', {
|
||||
ns: 'common',
|
||||
defaultValue: 'Member Details',
|
||||
})}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<Avatar
|
||||
avatar={member.avatar_url}
|
||||
name={member.name}
|
||||
size="2xl"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate system-md-semibold text-text-primary">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="truncate system-xs-regular text-text-tertiary">
|
||||
{member.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-divider-subtle px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary">
|
||||
<span>
|
||||
{t('members.memberDetails.assignedRoles', {
|
||||
ns: 'common',
|
||||
defaultValue: 'Assigned Roles',
|
||||
})}
|
||||
</span>
|
||||
<span className="system-xs-medium text-text-tertiary">
|
||||
{assignedRoles.length}
|
||||
</span>
|
||||
</div>
|
||||
{canAssignRoles && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setAssignOpen(true)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mr-0.5 i-ri-add-line h-3.5 w-3.5"
|
||||
/>
|
||||
{t('members.memberDetails.assign', {
|
||||
ns: 'common',
|
||||
defaultValue: 'Assign',
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('members.memberDetails.generalGroup', {
|
||||
ns: 'common',
|
||||
defaultValue: 'GENERAL',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignedRoles.map(role => (
|
||||
<PermissionRoleChip
|
||||
key={role.key}
|
||||
roleKey={role.key}
|
||||
label={role.label}
|
||||
highlighted
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{assignOpen && (
|
||||
<AssignRolesModal
|
||||
open={assignOpen}
|
||||
member={member}
|
||||
onClose={() => setAssignOpen(false)}
|
||||
onSubmit={handleAssignSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MemberDetailsModal)
|
||||
@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRolePermissionKeys } from './role-permissions'
|
||||
|
||||
export type PermissionRoleChipProps = {
|
||||
roleKey: string
|
||||
label: string
|
||||
highlighted?: boolean
|
||||
onRemove?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionRoleChip = ({
|
||||
roleKey,
|
||||
label,
|
||||
highlighted = false,
|
||||
onRemove,
|
||||
className,
|
||||
}: PermissionRoleChipProps) => {
|
||||
const { t } = useTranslation()
|
||||
const permissions = getRolePermissionKeys(roleKey)
|
||||
const hasPermissions = permissions.length > 0
|
||||
|
||||
const chip = (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-6 max-w-full cursor-default items-center gap-1 rounded-md px-1.5 system-xs-medium shadow-xs',
|
||||
highlighted
|
||||
? 'bg-state-accent-hover text-text-accent'
|
||||
: 'bg-background-body text-text-secondary',
|
||||
className,
|
||||
)}
|
||||
data-testid="permission-role-chip"
|
||||
data-role-key={roleKey}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.memberDetails.removeRoleAria', {
|
||||
ns: 'common',
|
||||
role: label,
|
||||
defaultValue: 'Remove {{role}} role',
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-4 w-4 items-center justify-center rounded hover:bg-black/5',
|
||||
highlighted ? 'text-text-accent' : 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!hasPermissions)
|
||||
return chip
|
||||
|
||||
return (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger render={chip} />
|
||||
<PreviewCardContent
|
||||
placement="bottom-start"
|
||||
popupClassName="min-w-[200px] max-w-[280px] p-3"
|
||||
>
|
||||
<div className="mb-2 system-sm-semibold text-text-accent">
|
||||
{label}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1.5 system-xs-regular text-text-secondary">
|
||||
{permissions.map(key => (
|
||||
<li key={key} className="flex items-start gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-[7px] inline-block h-1 w-1 shrink-0 rounded-full bg-text-tertiary"
|
||||
/>
|
||||
<span>
|
||||
{t(`members.memberDetails.permissions.${key}`, {
|
||||
ns: 'common',
|
||||
defaultValue: key,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PermissionRoleChip)
|
||||
@ -1,36 +0,0 @@
|
||||
// TODO: replace with permissions fetched from the permissions API once available.
|
||||
// Mock mapping from a workspace role key to the list of i18n keys describing
|
||||
// what permission points that role grants.
|
||||
export const ROLE_PERMISSION_KEYS: Record<string, string[]> = {
|
||||
owner: [
|
||||
'inviteMembers',
|
||||
'removeMembers',
|
||||
'assignRoles',
|
||||
'workspaceSettings',
|
||||
'manageBilling',
|
||||
'transferOwnership',
|
||||
],
|
||||
admin: [
|
||||
'inviteMembers',
|
||||
'removeMembers',
|
||||
'assignRoles',
|
||||
'workspaceSettings',
|
||||
'manageBilling',
|
||||
],
|
||||
editor: [
|
||||
'createApps',
|
||||
'editApps',
|
||||
'createDatasets',
|
||||
'editDatasets',
|
||||
],
|
||||
dataset_operator: [
|
||||
'manageDatasets',
|
||||
],
|
||||
normal: [
|
||||
'useApps',
|
||||
],
|
||||
}
|
||||
|
||||
export const getRolePermissionKeys = (roleKey: string): string[] => {
|
||||
return ROLE_PERMISSION_KEYS[roleKey] ?? []
|
||||
}
|
||||
@ -1,134 +0,0 @@
|
||||
'use client'
|
||||
import type { Member } from '@/models/common'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { deleteMemberOrCancelInvitation } from '@/service/common'
|
||||
import AssignRolesModal from './assign-roles-modal'
|
||||
|
||||
type MemberMenuProps = {
|
||||
member: Member
|
||||
operatorRole: string
|
||||
canTransferOwnership?: boolean
|
||||
onOperate: () => void
|
||||
onTransferOwnership?: () => void
|
||||
}
|
||||
|
||||
const MemberMenu = ({
|
||||
member,
|
||||
operatorRole,
|
||||
canTransferOwnership = false,
|
||||
onOperate,
|
||||
onTransferOwnership,
|
||||
}: MemberMenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [assignModalOpen, setAssignModalOpen] = useState(false)
|
||||
|
||||
const isOwner = member.role === 'owner'
|
||||
const canAssignRoles
|
||||
= !isOwner && (operatorRole === 'owner' || operatorRole === 'admin')
|
||||
const canRemove = !isOwner
|
||||
const showTransferOwnership = isOwner && canTransferOwnership
|
||||
|
||||
if (!canAssignRoles && !canRemove && !showTransferOwnership)
|
||||
return null
|
||||
|
||||
const handleOpenAssignRoles = () => {
|
||||
setOpen(false)
|
||||
setAssignModalOpen(true)
|
||||
}
|
||||
|
||||
const handleAssignRolesSubmit = (_roleIds: string[]) => {
|
||||
// TODO: wire to backend once multi-role member endpoint is ready.
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
onOperate()
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
|
||||
onOperate()
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransferOwnership = () => {
|
||||
setOpen(false)
|
||||
onTransferOwnership?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
aria-label={t('members.memberActions', { ns: 'common', defaultValue: 'Member 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-[180px] rounded-xl p-1"
|
||||
>
|
||||
{canAssignRoles && (
|
||||
<DropdownMenuItem
|
||||
className="system-sm-medium text-text-secondary"
|
||||
onClick={handleOpenAssignRoles}
|
||||
>
|
||||
{t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showTransferOwnership && (
|
||||
<DropdownMenuItem
|
||||
className="system-sm-medium text-text-secondary"
|
||||
onClick={handleTransferOwnership}
|
||||
>
|
||||
{t('members.transferOwnership', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(canAssignRoles || showTransferOwnership) && canRemove && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{canRemove && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="system-sm-medium"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
{t('members.removeFromTeam', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{assignModalOpen && (
|
||||
<AssignRolesModal
|
||||
open={assignModalOpen}
|
||||
member={member}
|
||||
onClose={() => setAssignModalOpen(false)}
|
||||
onSubmit={handleAssignRolesSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MemberMenu)
|
||||
@ -1,117 +0,0 @@
|
||||
'use client'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import MemberMenu from './member-menu'
|
||||
import RoleBadges from './role-badges'
|
||||
|
||||
type MemberRowProps = {
|
||||
member: Member
|
||||
roleLabel: string
|
||||
isCurrentUser: boolean
|
||||
canManage: boolean
|
||||
operatorRole: string
|
||||
canTransferOwnership: boolean
|
||||
onOpenDetails: (member: Member) => void
|
||||
onOperate: () => void
|
||||
onTransferOwnership: () => void
|
||||
}
|
||||
|
||||
const MemberRow = ({
|
||||
member,
|
||||
roleLabel,
|
||||
isCurrentUser,
|
||||
canManage,
|
||||
operatorRole,
|
||||
canTransferOwnership,
|
||||
onOpenDetails,
|
||||
onOperate,
|
||||
onTransferOwnership,
|
||||
}: MemberRowProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
const openDetails = useCallback(() => {
|
||||
onOpenDetails(member)
|
||||
}, [member, onOpenDetails])
|
||||
|
||||
const handleRowKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
}, [openDetails])
|
||||
|
||||
const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const stopPropagationOnKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`member-row-${member.id}`}
|
||||
aria-label={t('members.memberDetails.openAria', {
|
||||
ns: 'common',
|
||||
name: member.name,
|
||||
defaultValue: 'Open member details for {{name}}',
|
||||
})}
|
||||
className="flex cursor-pointer border-b border-divider-subtle hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
|
||||
onClick={openDetails}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
>
|
||||
<div className="flex grow items-center px-3 py-2">
|
||||
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
|
||||
<div className="">
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{member.name}
|
||||
{member.status === 'pending' && (
|
||||
<span className="ml-1 system-xs-medium text-text-warning">
|
||||
{t('members.pending', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('members.you', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-[120px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">
|
||||
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
|
||||
</div>
|
||||
<div
|
||||
className="flex w-[215px] shrink-0 items-center gap-2 px-3"
|
||||
onClick={stopPropagationOnClick}
|
||||
onKeyDown={stopPropagationOnKeyDown}
|
||||
role="presentation"
|
||||
>
|
||||
<RoleBadges
|
||||
className="grow"
|
||||
roles={[roleLabel]}
|
||||
/>
|
||||
{canManage && (
|
||||
<MemberMenu
|
||||
member={member}
|
||||
operatorRole={operatorRole}
|
||||
canTransferOwnership={canTransferOwnership}
|
||||
onOperate={onOperate}
|
||||
onTransferOwnership={onTransferOwnership}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MemberRow)
|
||||
@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
|
||||
type RoleBadgeProps = {
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RoleBadge = ({ label, className }: RoleBadgeProps) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 max-w-full items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-secondary shadow-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export type RoleBadgesProps = {
|
||||
roles: string[]
|
||||
max?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RoleBadges = ({ roles, max = 2, className }: RoleBadgesProps) => {
|
||||
if (!roles.length)
|
||||
return null
|
||||
|
||||
const visible = roles.slice(0, max)
|
||||
const overflow = roles.slice(max)
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}>
|
||||
{visible.map(role => (
|
||||
<RoleBadge key={role} label={role} />
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<span
|
||||
className="inline-flex h-5 cursor-default items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-tertiary shadow-xs"
|
||||
>
|
||||
{`+${overflow.length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RoleBadges)
|
||||
@ -1,27 +0,0 @@
|
||||
import type { RoleListGroup } from './role-list'
|
||||
import type { RoleListResponse } from '@/models/access-control'
|
||||
|
||||
export const formatRoleGroups = (roleListResponse: RoleListResponse | undefined): RoleListGroup[] => {
|
||||
if (!roleListResponse)
|
||||
return []
|
||||
const result: RoleListGroup[] = []
|
||||
const builtinRoles = roleListResponse.data.filter(role => role.is_builtin)
|
||||
const customRoles = roleListResponse.data.filter(role => !role.is_builtin)
|
||||
if (builtinRoles.length > 0) {
|
||||
result.push({
|
||||
id: 'builtin',
|
||||
category: 'global_system_default',
|
||||
title: 'System Roles',
|
||||
items: builtinRoles,
|
||||
})
|
||||
}
|
||||
if (customRoles.length > 0) {
|
||||
result.push({
|
||||
id: 'custom',
|
||||
category: 'global_custom',
|
||||
title: 'Custom Roles',
|
||||
items: customRoles,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import type { PaginationParameters } from '@/models/access-control'
|
||||
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
|
||||
import { formatRoleGroups } from './helpers'
|
||||
|
||||
export const useRoleGroups = (params?: PaginationParameters) => {
|
||||
const { data: roleList, isLoading } = useWorkspaceRoleList(params)
|
||||
|
||||
const roleGroups = formatRoleGroups(roleList)
|
||||
|
||||
return {
|
||||
roleGroups,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { RoleModalMode, submitRoleData } from './role-modal'
|
||||
import type { Role } from '@/models/access-control'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles'
|
||||
import { useRoleGroups } from './hooks'
|
||||
import RoleList from './role-list'
|
||||
import RoleModal from './role-modal'
|
||||
|
||||
type ModalState = {
|
||||
mode: RoleModalMode
|
||||
role?: Role
|
||||
} | null
|
||||
|
||||
const PermissionsPage = () => {
|
||||
const [modalState, setModalState] = useState<ModalState>(null)
|
||||
|
||||
const { roleGroups } = useRoleGroups()
|
||||
|
||||
const { mutateAsync: createWorkspaceRole } = useCreateWorkspaceRole()
|
||||
const { mutateAsync: updateWorkspaceRole } = useUpdateWorkspaceRole()
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setModalState({ mode: 'create' })
|
||||
}, [])
|
||||
|
||||
const handleView = useCallback((role: Role) => {
|
||||
setModalState({ mode: 'view', role })
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback((role: Role) => {
|
||||
setModalState({ mode: 'edit', role })
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => setModalState(null), [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: submitRoleData) => {
|
||||
const { name, description, permissionKeys } = data
|
||||
const mode = modalState?.mode ?? ''
|
||||
const roleId = modalState?.role?.id ?? ''
|
||||
if (mode === 'create') {
|
||||
createWorkspaceRole({ name, description, permission_keys: permissionKeys }, {
|
||||
onSuccess: () => {
|
||||
toast.success('Role created successfully')
|
||||
closeModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
else if (mode === 'edit') {
|
||||
updateWorkspaceRole({ id: roleId, name, description, permission_keys: permissionKeys }, {
|
||||
onSuccess: () => {
|
||||
toast.success('Role updated successfully')
|
||||
closeModal()
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[createWorkspaceRole, updateWorkspaceRole, closeModal, modalState],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-subtle bg-linear-to-bl from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5">
|
||||
<div className="flex grow flex-col gap-y-1">
|
||||
<div className="system-md-semibold text-text-primary">
|
||||
Default Global
|
||||
</div>
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
A default global permission scheme applied to the workspace
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<RoleList
|
||||
groups={roleGroups}
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
</div>
|
||||
{modalState && (
|
||||
<RoleModal
|
||||
mode={modalState?.mode ?? 'create'}
|
||||
open
|
||||
role={modalState?.role}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionsPage
|
||||
@ -1,59 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Role, RoleCategory } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import Row from './row'
|
||||
|
||||
export type RoleListGroup = {
|
||||
id: string
|
||||
category: RoleCategory
|
||||
title: string
|
||||
items: Role[]
|
||||
}
|
||||
|
||||
export type RoleListProps = {
|
||||
groups: RoleListGroup[]
|
||||
className?: string
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
}
|
||||
|
||||
const RoleList = ({
|
||||
groups,
|
||||
className,
|
||||
onView,
|
||||
onEdit,
|
||||
}: RoleListProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<section
|
||||
key={group.id}
|
||||
className={cn(groupIndex > 0 && 'mt-6')}
|
||||
>
|
||||
<h3 className="mb-2 pr-3 system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="overflow-hidden">
|
||||
{group.items.map((row, rowIndex) => (
|
||||
<Row
|
||||
key={row.id}
|
||||
className={cn(
|
||||
rowIndex > 0 && 'border-t border-divider-subtle',
|
||||
)}
|
||||
name={row.name}
|
||||
description={row.description}
|
||||
roleCategory={group.category}
|
||||
role={row}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleList
|
||||
@ -1,90 +0,0 @@
|
||||
'use client'
|
||||
import type { Role, RoleCategory } 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 { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles'
|
||||
|
||||
type RowMenuProps = {
|
||||
roleCategory: RoleCategory
|
||||
role: Role
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
}
|
||||
|
||||
const RowMenu = ({
|
||||
roleCategory,
|
||||
role,
|
||||
onView,
|
||||
onEdit,
|
||||
}: RowMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleView = useCallback(() => onView?.(role), [onView, role])
|
||||
|
||||
const handleEdit = useCallback(() => onEdit?.(role), [onEdit, role])
|
||||
|
||||
const { mutateAsync: copyRole } = useCopyWorkspaceRole()
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
copyRole(role.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Role duplicated successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [copyRole, role.id])
|
||||
|
||||
const { mutateAsync: deleteRole } = useDeleteWorkspaceRole()
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteRole(role.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('Role deleted successfully')
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
}, [deleteRole, role.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-[160px]">
|
||||
{
|
||||
roleCategory === 'global_system_default' && (
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleView}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
{
|
||||
roleCategory === 'global_custom' && (
|
||||
<>
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleEdit}>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleDuplicate}>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" className="system-sm-semibold" onClick={handleDelete}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default RowMenu
|
||||
@ -1,50 +0,0 @@
|
||||
import type { Role, RoleCategory } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
import RowMenu from './row-menu'
|
||||
|
||||
type RowProps = {
|
||||
className?: string
|
||||
name: string
|
||||
description: string
|
||||
roleCategory: RoleCategory
|
||||
role: Role
|
||||
onView?: (role: Role) => void
|
||||
onEdit?: (role: Role) => void
|
||||
}
|
||||
|
||||
const Row = ({
|
||||
className,
|
||||
name,
|
||||
description,
|
||||
roleCategory,
|
||||
role,
|
||||
onView,
|
||||
onEdit,
|
||||
}: RowProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 py-3.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{name}
|
||||
</div>
|
||||
<p className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<RowMenu
|
||||
roleCategory={roleCategory}
|
||||
role={role}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Row)
|
||||
@ -1,18 +0,0 @@
|
||||
import { useWorkspacePermissionCatalog } from '@/service/access-control/use-permission-catalog'
|
||||
|
||||
export const useWorkspacePermissionGroups = () => {
|
||||
const { data: workspacePermissionCatalog } = useWorkspacePermissionCatalog()
|
||||
|
||||
const groups = workspacePermissionCatalog?.groups || []
|
||||
|
||||
const allPermissions = groups.flatMap(g => g.permissions) || []
|
||||
|
||||
const permissionMap = Object.fromEntries(
|
||||
allPermissions.map(p => [p.key, p]),
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
permissionMap,
|
||||
}
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Role } from '@/models/access-control'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PermissionField from './permission-field'
|
||||
|
||||
export type RoleModalMode = 'create' | 'view' | 'edit'
|
||||
|
||||
export type submitRoleData = {
|
||||
name: string
|
||||
description?: string
|
||||
permissionKeys?: string[]
|
||||
}
|
||||
|
||||
export type RoleModalProps = {
|
||||
mode: RoleModalMode
|
||||
open: boolean
|
||||
role?: Role
|
||||
onClose: () => void
|
||||
onSubmit?: (data: submitRoleData) => void
|
||||
}
|
||||
|
||||
const TITLES: Record<RoleModalMode, { title: string, description: string }> = {
|
||||
create: {
|
||||
title: 'Create Role',
|
||||
description: 'Create a role and assign permissions',
|
||||
},
|
||||
edit: {
|
||||
title: 'Edit Role',
|
||||
description: 'Edit role details and permissions',
|
||||
},
|
||||
view: {
|
||||
title: 'View Role',
|
||||
description: 'View role details and permissions',
|
||||
},
|
||||
}
|
||||
|
||||
const RoleModal = ({
|
||||
mode,
|
||||
open,
|
||||
role,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: RoleModalProps) => {
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [desc, setDesc] = useState(role?.description ?? '')
|
||||
const [permissionKeys, setPermissionKeys] = useState<string[]>(role?.permission_keys ?? [])
|
||||
|
||||
const readonly = mode === 'view'
|
||||
const { title, description } = TITLES[mode]
|
||||
|
||||
const onRoleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value)
|
||||
}, [])
|
||||
|
||||
const onRoleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDesc(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit?.({ name: name.trim(), description: desc.trim(), permissionKeys })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="w-[560px] overflow-visible p-0"
|
||||
backdropProps={{ forceRender: true }}
|
||||
>
|
||||
<div className="relative px-6 pt-6 pb-4">
|
||||
<DialogCloseButton />
|
||||
<div className="pr-8">
|
||||
<DialogTitle className="system-xl-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-divider-subtle" />
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="role-name" className="system-sm-medium text-text-secondary">
|
||||
Role name
|
||||
</label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={name}
|
||||
onChange={onRoleNameChange}
|
||||
placeholder="e.g. Marketing Lead"
|
||||
disabled={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="role-description" className="system-sm-medium text-text-secondary">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={desc}
|
||||
onChange={onRoleDescChange}
|
||||
placeholder="Describe what this role is responsible for"
|
||||
disabled={readonly}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<PermissionField
|
||||
value={permissionKeys}
|
||||
onChange={setPermissionKeys}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<a
|
||||
href="https://docs.dify.ai/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
<span>Learn more about permissions</span>
|
||||
<span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" />
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{readonly ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!readonly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim()}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleModal
|
||||
@ -1,71 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useWorkspacePermissionGroups } from './hooks'
|
||||
import PermissionPicker from './permission-picker'
|
||||
|
||||
export type PermissionFieldProps = {
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const PermissionField = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: PermissionFieldProps) => {
|
||||
const { permissionMap } = useWorkspacePermissionGroups()
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(value.filter(p => p !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="system-sm-medium text-text-secondary">Permissions</div>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((key) => {
|
||||
const p = permissionMap[key]
|
||||
if (!p)
|
||||
return null
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md bg-util-colors-indigo-indigo-50 px-1.5 py-0.5 system-xs-medium text-text-accent',
|
||||
'border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
{!readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-3.5 w-3.5 items-center justify-center rounded hover:bg-state-base-hover"
|
||||
aria-label={`Remove ${p.name}`}
|
||||
onClick={() => handleRemove(key)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
value.length === 0 && (
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
No permissions assigned yet
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{!readonly && (
|
||||
<PermissionPicker value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionField
|
||||
@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { PermissionGroup } from '@/models/access-control'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { useWorkspacePermissionGroups } from './hooks'
|
||||
|
||||
type PermissionPickerProps = {
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PermissionPicker = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: PermissionPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Base UI Menu's FloatingFocusManager hard-codes `initialFocus: true` for top-level
|
||||
// menus, which steals focus from the trigger input on open. Re-focus the input on the
|
||||
// next tick so the user can keep typing to filter permissions.
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
const { groups } = useWorkspacePermissionGroups()
|
||||
|
||||
const filteredGroups = useMemo<PermissionGroup[]>(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q)
|
||||
return groups
|
||||
return groups
|
||||
.map(group => ({
|
||||
...group,
|
||||
permissions: group.permissions.filter(i => i.name.toLowerCase().includes(q)),
|
||||
}))
|
||||
.filter(group => group.permissions.length > 0)
|
||||
}, [search, groups])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value), [value])
|
||||
|
||||
const togglePermission = (id: string) => {
|
||||
if (selectedSet.has(id))
|
||||
onChange(value.filter(v => v !== id))
|
||||
else
|
||||
onChange([...value, id])
|
||||
}
|
||||
|
||||
const getGroupState = (group: PermissionGroup) => {
|
||||
const checkedCount = group.permissions.reduce(
|
||||
(acc, i) => acc + (selectedSet.has(i.key) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
return {
|
||||
allChecked: checkedCount > 0 && checkedCount === group.permissions.length,
|
||||
indeterminate: checkedCount > 0 && checkedCount < group.permissions.length,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGroup = (group: PermissionGroup) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
const ids = group.permissions.map(i => i.key)
|
||||
if (allChecked || indeterminate) {
|
||||
const idSet = new Set(ids)
|
||||
onChange(value.filter(v => !idSet.has(v)))
|
||||
}
|
||||
else {
|
||||
const next = new Set(value)
|
||||
ids.forEach(id => next.add(id))
|
||||
onChange(Array.from(next))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-text items-center gap-2 rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-components-input-bg-hover',
|
||||
open && 'bg-components-input-bg-active shadow-xs ring-[0.5px] ring-components-input-border-active',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 grow appearance-none bg-transparent system-sm-regular text-text-primary caret-primary-600 outline-hidden placeholder:text-text-tertiary"
|
||||
placeholder="Search permissions..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Escape')
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="max-h-80 w-[var(--anchor-width)]"
|
||||
>
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
No permissions found
|
||||
</div>
|
||||
)}
|
||||
{filteredGroups.map((group) => {
|
||||
const { allChecked, indeterminate } = getGroupState(group)
|
||||
return (
|
||||
<DropdownMenuGroup key={group.group_key}>
|
||||
<button
|
||||
type="button"
|
||||
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
|
||||
onClick={() => toggleGroup(group)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={indeterminate}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
|
||||
{group.group_name}
|
||||
</span>
|
||||
</button>
|
||||
{group.permissions.map((item) => {
|
||||
const checked = selectedSet.has(item.key)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.key}
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePermission(item.key)}
|
||||
className="gap-2 pl-6"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{item.name}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionPicker
|
||||
@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={
|
||||
cn(
|
||||
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,10 +12,10 @@ export default function SignInLayout({ children }: any) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
<Header />
|
||||
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
|
||||
<div className="flex flex-col md:w-[400px]">
|
||||
<div className="flex w-full flex-col md:w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -168,7 +168,6 @@ export const ProviderContextProvider = ({
|
||||
isAllowTransferWorkspace,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate,
|
||||
humanInputEmailDeliveryEnabled,
|
||||
enableAccessControl: true, // todo: get from backend
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -43,7 +43,6 @@ export type ProviderContextState = {
|
||||
isAllowTransferWorkspace: boolean
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: boolean
|
||||
humanInputEmailDeliveryEnabled: boolean
|
||||
enableAccessControl: boolean
|
||||
}
|
||||
|
||||
export const baseProviderContextValue: ProviderContextState = {
|
||||
@ -77,7 +76,6 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
enableAccessControl: true,
|
||||
}
|
||||
|
||||
export const ProviderContext = createContext<ProviderContextState>(baseProviderContextValue)
|
||||
|
||||
@ -216,12 +216,6 @@
|
||||
"loading": "Loading",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Can build apps & manage team settings",
|
||||
"members.assignRoles": "Assign Roles",
|
||||
"members.assignRolesModal.description": "Select roles to assign to this member. All permissions from selected roles will be combined.",
|
||||
"members.assignRolesModal.empty": "No matching roles",
|
||||
"members.assignRolesModal.searchPlaceholder": "Search roles...",
|
||||
"members.assignRolesModal.selectedCount": "{{count}} selected",
|
||||
"members.assignRolesModal.title": "Assign Roles",
|
||||
"members.builder": "Builder",
|
||||
"members.builderTip": "Can build & edit own apps",
|
||||
"members.datasetOperator": "Knowledge Admin",
|
||||
@ -243,25 +237,6 @@
|
||||
"members.inviteTeamMemberTip": "They can access your team data directly after signing in.",
|
||||
"members.invitedAsRole": "Invited as {{role}} user",
|
||||
"members.lastActive": "LAST ACTIVE",
|
||||
"members.memberActions": "Member actions",
|
||||
"members.memberDetails.assign": "Assign",
|
||||
"members.memberDetails.assignedRoles": "Assigned Roles",
|
||||
"members.memberDetails.generalGroup": "GENERAL",
|
||||
"members.memberDetails.openAria": "Open member details for {{name}}",
|
||||
"members.memberDetails.permissions.assignRoles": "Assign roles",
|
||||
"members.memberDetails.permissions.createApps": "Create apps",
|
||||
"members.memberDetails.permissions.createDatasets": "Create knowledge",
|
||||
"members.memberDetails.permissions.editApps": "Edit apps",
|
||||
"members.memberDetails.permissions.editDatasets": "Edit knowledge",
|
||||
"members.memberDetails.permissions.inviteMembers": "Invite members",
|
||||
"members.memberDetails.permissions.manageBilling": "Manage billing",
|
||||
"members.memberDetails.permissions.manageDatasets": "Manage knowledge",
|
||||
"members.memberDetails.permissions.removeMembers": "Remove members",
|
||||
"members.memberDetails.permissions.transferOwnership": "Transfer ownership",
|
||||
"members.memberDetails.permissions.useApps": "Use apps",
|
||||
"members.memberDetails.permissions.workspaceSettings": "Workspace settings",
|
||||
"members.memberDetails.removeRoleAria": "Remove {{role}} role",
|
||||
"members.memberDetails.title": "Member Details",
|
||||
"members.name": "NAME",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Only can use apps, can not build apps",
|
||||
@ -632,7 +607,6 @@
|
||||
"provider.saveFailed": "Save api key failed",
|
||||
"provider.validatedError": "Validation failed: ",
|
||||
"provider.validating": "Validating key...",
|
||||
"settings.accessRules": "Access Rules",
|
||||
"settings.account": "My account",
|
||||
"settings.accountGroup": "GENERAL",
|
||||
"settings.apiBasedExtension": "API Extension",
|
||||
@ -642,7 +616,6 @@
|
||||
"settings.integrations": "Integrations",
|
||||
"settings.language": "Language",
|
||||
"settings.members": "Members",
|
||||
"settings.permissions": "Permissions",
|
||||
"settings.plugin": "Plugins",
|
||||
"settings.provider": "Model Provider",
|
||||
"settings.workplaceGroup": "WORKSPACE",
|
||||
|
||||
@ -216,12 +216,6 @@
|
||||
"loading": "加载中",
|
||||
"members.admin": "管理员",
|
||||
"members.adminTip": "能够建立应用程序和管理团队设置",
|
||||
"members.assignRoles": "分配角色",
|
||||
"members.assignRolesModal.description": "为该成员选择要分配的角色,所选角色的权限将被合并。",
|
||||
"members.assignRolesModal.empty": "没有匹配的角色",
|
||||
"members.assignRolesModal.searchPlaceholder": "搜索角色…",
|
||||
"members.assignRolesModal.selectedCount": "已选 {{count}} 项",
|
||||
"members.assignRolesModal.title": "分配角色",
|
||||
"members.builder": "构建器",
|
||||
"members.builderTip": "可以构建和编辑自己的应用程序",
|
||||
"members.datasetOperator": "知识库管理员",
|
||||
@ -243,25 +237,6 @@
|
||||
"members.inviteTeamMemberTip": "对方在登录后可以访问你的团队数据。",
|
||||
"members.invitedAsRole": "邀请为{{role}}用户",
|
||||
"members.lastActive": "上次活动时间",
|
||||
"members.memberActions": "成员操作",
|
||||
"members.memberDetails.assign": "分配",
|
||||
"members.memberDetails.assignedRoles": "已分配角色",
|
||||
"members.memberDetails.generalGroup": "通用角色",
|
||||
"members.memberDetails.openAria": "打开 {{name}} 的成员详情",
|
||||
"members.memberDetails.permissions.assignRoles": "分配角色",
|
||||
"members.memberDetails.permissions.createApps": "创建应用",
|
||||
"members.memberDetails.permissions.createDatasets": "创建知识库",
|
||||
"members.memberDetails.permissions.editApps": "编辑应用",
|
||||
"members.memberDetails.permissions.editDatasets": "编辑知识库",
|
||||
"members.memberDetails.permissions.inviteMembers": "邀请成员",
|
||||
"members.memberDetails.permissions.manageBilling": "管理订阅",
|
||||
"members.memberDetails.permissions.manageDatasets": "管理知识库",
|
||||
"members.memberDetails.permissions.removeMembers": "移除成员",
|
||||
"members.memberDetails.permissions.transferOwnership": "转移所有权",
|
||||
"members.memberDetails.permissions.useApps": "使用应用",
|
||||
"members.memberDetails.permissions.workspaceSettings": "工作空间设置",
|
||||
"members.memberDetails.removeRoleAria": "移除 {{role}} 角色",
|
||||
"members.memberDetails.title": "成员详情",
|
||||
"members.name": "姓名",
|
||||
"members.normal": "成员",
|
||||
"members.normalTip": "只能使用应用程序,不能建立应用程序",
|
||||
@ -632,7 +607,6 @@
|
||||
"provider.saveFailed": "API 密钥保存失败",
|
||||
"provider.validatedError": "校验失败:",
|
||||
"provider.validating": "验证密钥中...",
|
||||
"settings.accessRules": "访问规则",
|
||||
"settings.account": "我的账户",
|
||||
"settings.accountGroup": "通用",
|
||||
"settings.apiBasedExtension": "API 扩展",
|
||||
@ -642,7 +616,6 @@
|
||||
"settings.integrations": "集成",
|
||||
"settings.language": "语言",
|
||||
"settings.members": "成员",
|
||||
"settings.permissions": "权限",
|
||||
"settings.plugin": "插件",
|
||||
"settings.provider": "模型供应商",
|
||||
"settings.workplaceGroup": "工作空间",
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
export const SubjectType = {
|
||||
GROUP: 'group',
|
||||
ACCOUNT: 'account',
|
||||
} as const
|
||||
export enum SubjectType {
|
||||
GROUP = 'group',
|
||||
ACCOUNT = 'account',
|
||||
}
|
||||
|
||||
export type SubjectType = typeof SubjectType[keyof typeof SubjectType]
|
||||
|
||||
export const AccessMode = {
|
||||
PUBLIC: 'public',
|
||||
SPECIFIC_GROUPS_MEMBERS: 'private',
|
||||
ORGANIZATION: 'private_all',
|
||||
EXTERNAL_MEMBERS: 'sso_verified',
|
||||
} as const
|
||||
|
||||
export type AccessMode = typeof AccessMode[keyof typeof AccessMode]
|
||||
export enum AccessMode {
|
||||
PUBLIC = 'public',
|
||||
SPECIFIC_GROUPS_MEMBERS = 'private',
|
||||
ORGANIZATION = 'private_all',
|
||||
EXTERNAL_MEMBERS = 'sso_verified',
|
||||
}
|
||||
|
||||
export type AccessControlGroup = {
|
||||
id: 'string'
|
||||
@ -32,150 +28,3 @@ export type SubjectGroup = { subjectId: string, subjectType: SubjectType, groupD
|
||||
export type SubjectAccount = { subjectId: string, subjectType: SubjectType, accountData: AccessControlAccount }
|
||||
|
||||
export type Subject = SubjectGroup | SubjectAccount
|
||||
|
||||
export type Permission = {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
export type PermissionGroup = {
|
||||
group_key: string
|
||||
group_name: string
|
||||
description: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
export type PermissionGroups = {
|
||||
groups: PermissionGroup[]
|
||||
}
|
||||
|
||||
export type PermissionKey = string
|
||||
|
||||
export type RoleType = 'workspace' | 'app' | 'dataset'
|
||||
|
||||
export type RoleCategory = 'global_system_default' | 'global_custom'
|
||||
|
||||
export type Role = {
|
||||
id: string
|
||||
tenant_id: string
|
||||
type: RoleType
|
||||
category: RoleCategory
|
||||
name: string
|
||||
description: string
|
||||
is_builtin: boolean
|
||||
permission_keys: PermissionKey[]
|
||||
}
|
||||
|
||||
export type Pagination = {
|
||||
total_count: number
|
||||
per_page: number
|
||||
current_page: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export type PaginationParameters = {
|
||||
page?: number
|
||||
limit?: number
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
export type RoleListResponse = {
|
||||
data: Role[]
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type CreateRoleRequest = {
|
||||
name: string
|
||||
description?: string
|
||||
permission_keys?: PermissionKey[]
|
||||
}
|
||||
|
||||
export type UpdateRolesRequest = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
permission_keys?: PermissionKey[]
|
||||
}
|
||||
|
||||
export type AccessPolicyResourceType = 'app' | 'dataset'
|
||||
|
||||
export type AccessPolicyCategory = 'global_system_default' | 'global_custom'
|
||||
|
||||
export type AccessPolicy = {
|
||||
id: string
|
||||
tenant_id: string
|
||||
resource_type: AccessPolicyResourceType
|
||||
policy_key: string
|
||||
name: string
|
||||
description: string
|
||||
permission_keys: PermissionKey[]
|
||||
is_builtin: boolean
|
||||
category: AccessPolicyCategory
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CreateAccessPolicyRequest = {
|
||||
name: string
|
||||
description?: string
|
||||
permission_keys?: PermissionKey[]
|
||||
}
|
||||
|
||||
export type UpdateAccessPolicyRequest = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
permission_keys?: PermissionKey[]
|
||||
}
|
||||
|
||||
export type BindingType = 'role' | 'account'
|
||||
|
||||
export type Bindings = {
|
||||
role_ids: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
account_ids: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type BindingsPayload = {
|
||||
role_ids: string[]
|
||||
account_ids: string[]
|
||||
}
|
||||
|
||||
export type AccessPolicyWithBindings = {
|
||||
policy: AccessPolicy
|
||||
} & Bindings
|
||||
|
||||
export type GetAppAccessPolicyByAppIdResponse = {
|
||||
app_id: string
|
||||
items: AccessPolicyWithBindings[]
|
||||
}
|
||||
|
||||
export type GetDatasetAccessPolicyByDatasetIdResponse = {
|
||||
dataset_id: string
|
||||
items: AccessPolicyWithBindings[]
|
||||
}
|
||||
|
||||
export type GetAppAccessPoliciesResponse = {
|
||||
items: AccessPolicyWithBindings[]
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type GetDatasetAccessPoliciesResponse = {
|
||||
items: AccessPolicyWithBindings[]
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type UpdateRolesOfMemberRequest = {
|
||||
member_id: string
|
||||
role_ids: string[]
|
||||
}
|
||||
|
||||
export type UpdateRolesOfMemberResponse = {
|
||||
account_id: string
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@ import type { AccessControlAccount, AccessControlGroup, AccessMode, Subject } fr
|
||||
import type { App } from '@/types/app'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { get, post } from '../base'
|
||||
import { getUserCanAccess } from '../share'
|
||||
import { get, post } from './base'
|
||||
import { getUserCanAccess } from './share'
|
||||
|
||||
const NAME_SPACE = 'access-control'
|
||||
|
||||
@ -24,25 +24,16 @@ type SearchResults = {
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type SearchForWhiteListCandidatesQuery = {
|
||||
keyword?: string
|
||||
groupId?: AccessControlGroup['id']
|
||||
resultsPerPage?: number
|
||||
}
|
||||
|
||||
export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandidatesQuery, enabled: boolean) => {
|
||||
const { keyword, groupId, resultsPerPage } = query
|
||||
|
||||
export const useSearchForWhiteListCandidates = (query: { keyword?: string, groupId?: AccessControlGroup['id'], resultsPerPage?: number }, enabled: boolean) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [NAME_SPACE, 'app-whitelist-candidates', keyword, groupId, resultsPerPage],
|
||||
queryKey: [NAME_SPACE, 'app-whitelist-candidates', query],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (keyword)
|
||||
params.append('keyword', keyword)
|
||||
if (groupId)
|
||||
params.append('groupId', groupId)
|
||||
if (resultsPerPage)
|
||||
params.append('resultsPerPage', `${resultsPerPage}`)
|
||||
Object.keys(query).forEach((key) => {
|
||||
const typedKey = key as keyof typeof query
|
||||
if (query[typedKey])
|
||||
params.append(key, `${query[typedKey]}`)
|
||||
})
|
||||
params.append('pageNumber', `${pageParam}`)
|
||||
return get<SearchResults>(`/enterprise/webapp/app/subject/search?${new URLSearchParams(params).toString()}`)
|
||||
},
|
||||
@ -1,30 +0,0 @@
|
||||
import type {
|
||||
PermissionGroups,
|
||||
} from '@/models/access-control'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from '../base'
|
||||
|
||||
const NAME_SPACE = 'rbac-permission-catalog'
|
||||
|
||||
export const useWorkspacePermissionCatalog = () => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'workspace'],
|
||||
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog'),
|
||||
})
|
||||
}
|
||||
|
||||
export const useAppPermissionCatalog = (enabled?: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'app'],
|
||||
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/app'),
|
||||
enabled: enabled ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDatasetPermissionCatalog = (enabled?: boolean) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'dataset'],
|
||||
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/dataset'),
|
||||
enabled: enabled ?? true,
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user