feat: rbac service (#35874)

Co-authored-by: twwu <twwu@dify.ai>
This commit is contained in:
wangxiaolei 2026-05-07 14:16:02 +08:00 committed by GitHub
parent fca92cc1d1
commit 3a525a609c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 5930 additions and 96 deletions

View File

@ -139,6 +139,7 @@ from .workspace import (
model_providers,
models,
plugin,
rbac,
snippets,
tool_providers,
trigger_providers,
@ -208,6 +209,7 @@ __all__ = [
"rag_pipeline_draft_variable",
"rag_pipeline_import",
"rag_pipeline_workflow",
"rbac",
"recommended_app",
"saved_message",
"setup",

View File

@ -0,0 +1,643 @@
"""Dify Console controllers that proxy the enterprise RBAC surface.
Each route here is a thin adapter: it validates the pydantic payload shown in
the screenshots (`Settings > Permissions`, `Settings > Access Rules`,
`App/Knowledge Base Access Config`, and the `Settings > Members` role
assignment dialog), pulls ``tenant_id`` / ``account_id`` from the current
Dify session and forwards to the inner RBAC client defined in
``services/enterprise/rbac_service.py``. The client then calls the
``/inner/api/rbac/*`` endpoints on dify-enterprise over HTTP using the
shared ``Enterprise-Api-Secret-Key`` header.
"""
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import current_account_with_tenant, login_required
from services.enterprise import rbac_service as svc
# ---------------------------------------------------------------------------
# Shared helpers.
# ---------------------------------------------------------------------------
def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Reject every call when the Dify install is not running in enterprise
mode. The dashboard UI shown in the screenshots is an enterprise-only
feature, so every route here should fail fast (and clearly) in community.
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not dify_config.ENTERPRISE_ENABLED:
raise Forbidden("Enterprise edition is not enabled")
return view(*args, **kwargs)
return decorated
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")
# ---------------------------------------------------------------------------
# Permission catalogs.
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog")
class RBACWorkspaceCatalogApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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
role_key: str
description: str = ""
permission_keys: list[str] = []
def to_mutation(self) -> svc.RoleMutation:
return svc.RoleMutation(
name=self.name,
role_key=self.role_key,
description=self.description,
permission_keys=list(self.permission_keys),
)
@console_ns.route("/workspaces/current/rbac/roles")
class RBACRolesApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def get(self):
tenant_id, account_id = _current_ids()
options = svc.ListOption()
return _dump(svc.RBACService.Roles.list(tenant_id, account_id, options=options))
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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)))
@enterprise_only
@setup_required
@login_required
@account_initialization_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)
@enterprise_only
@setup_required
@login_required
@account_initialization_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"}
# ---------------------------------------------------------------------------
# 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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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.
from flask import request
resource_type = request.args.get("resource_type") or None
return _dump(
svc.RBACService.AccessPolicies.list(
tenant_id,
account_id,
resource_type=resource_type,
options=svc.ListOption(),
)
)
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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)))
@enterprise_only
@setup_required
@login_required
@account_initialization_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)
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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 _ReplaceRoleBindingsRequest(BaseModel):
role_keys: list[str] = []
class _ReplaceMemberBindingsRequest(BaseModel):
account_ids: list[str] = []
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policy")
class RBACAppMatrixApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceRoleBindingsRequest)
return _dump(
svc.RBACService.AppAccess.replace_role_bindings(
tenant_id,
account_id,
str(app_id),
str(policy_id),
svc.ReplaceRoleBindings(role_keys=list(request.role_keys)),
)
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/member-bindings")
class RBACAppMemberBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberBindingsRequest)
return _dump(
svc.RBACService.AppAccess.replace_member_bindings(
tenant_id,
account_id,
str(app_id),
str(policy_id),
svc.ReplaceMemberBindings(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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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):
@enterprise_only
@setup_required
@login_required
@account_initialization_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)
)
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceRoleBindingsRequest)
return _dump(
svc.RBACService.DatasetAccess.replace_role_bindings(
tenant_id,
account_id,
str(dataset_id),
str(policy_id),
svc.ReplaceRoleBindings(role_keys=list(request.role_keys)),
)
)
@console_ns.route(
"/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/member-bindings"
)
class RBACDatasetMemberBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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)
)
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberBindingsRequest)
return _dump(
svc.RBACService.DatasetAccess.replace_member_bindings(
tenant_id,
account_id,
str(dataset_id),
str(policy_id),
svc.ReplaceMemberBindings(account_ids=list(request.account_ids)),
)
)
# ---------------------------------------------------------------------------
# Workspace-level access (Settings > Access Rules).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy")
class RBACWorkspaceAppMatrixApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceAppRoleBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceRoleBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_app_role_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceRoleBindings(role_keys=list(request.role_keys)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceAppMemberBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_app_member_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceMemberBindings(account_ids=list(request.account_ids)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy")
class RBACWorkspaceDatasetMatrixApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceDatasetRoleBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceRoleBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_dataset_role_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceRoleBindings(role_keys=list(request.role_keys)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceDatasetMemberBindingsApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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))
)
@enterprise_only
@setup_required
@login_required
@account_initialization_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_dataset_member_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceMemberBindings(account_ids=list(request.account_ids)),
)
)
# ---------------------------------------------------------------------------
# Member ↔ role bindings (Settings > Members > Assign roles).
# ---------------------------------------------------------------------------
class _ReplaceMemberRolesRequest(BaseModel):
role_keys: list[str] = []
@console_ns.route("/workspaces/current/rbac/members/<uuid:member_id>/rbac-roles")
class RBACMemberRolesApi(Resource):
@enterprise_only
@setup_required
@login_required
@account_initialization_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)))
@enterprise_only
@setup_required
@login_required
@account_initialization_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_keys=list(request.role_keys),
)
)

View File

@ -5,6 +5,7 @@ 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,
@ -16,6 +17,11 @@ 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 = {
@ -49,8 +55,16 @@ 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()
@ -122,6 +136,43 @@ class EnterpriseRequest(BaseRequest):
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 dify_config.ENTERPRISE_ENABLED:
raise EnterpriseAPIError("Enterprise edition is not enabled")
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
return cls.send_request(
method,
endpoint,
json=json,
params=params,
timeout=timeout,
extra_headers=inner_headers,
)
class EnterprisePluginManagerRequest(BaseRequest):
base_url = os.environ.get("ENTERPRISE_PLUGIN_MANAGER_API_URL", "ENTERPRISE_PLUGIN_MANAGER_API_URL")

View File

@ -0,0 +1,774 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from services.enterprise.base import EnterpriseRequest
T = TypeVar("T")
class _RBACModel(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="ignore")
class Pagination(_RBACModel):
total_count: int = 0
per_page: int = 0
current_page: int = 0
total_pages: int = 0
class Paginated(_RBACModel, Generic[T]):
data: list[T] = Field(default_factory=list)
pagination: Pagination | None = None
class RBACResourceType(StrEnum):
"""Resource types understood by access policies."""
APP = "app"
DATASET = "dataset"
class RBACRoleType(StrEnum):
"""The only concrete role type after the access-policy refactor."""
WORKSPACE = "workspace"
class PermissionCatalogItem(_RBACModel):
key: str
name: str
description: str = ""
class PermissionCatalogGroup(_RBACModel):
group_key: str
group_name: str
description: str = ""
permissions: list[PermissionCatalogItem] = Field(default_factory=list)
class PermissionCatalogResponse(_RBACModel):
groups: list[PermissionCatalogGroup] = Field(default_factory=list)
class RBACRole(_RBACModel):
id: str
tenant_id: str | None = None
type: str
category: str = ""
role_key: str
name: str
description: str = ""
is_builtin: bool = False
permission_keys: list[str] = Field(default_factory=list)
class AccessPolicy(_RBACModel):
id: str
tenant_id: str = ""
resource_type: str
policy_key: str = ""
name: str
description: str = ""
permission_keys: list[str] = Field(default_factory=list)
is_builtin: bool = False
category: str = ""
created_at: int = 0
updated_at: int = 0
class AccessPolicyRoleBinding(_RBACModel):
id: str
tenant_id: str = ""
access_policy_id: str
resource_type: str
resource_id: str = ""
role_key: str
created_at: int = 0
class AccessPolicyMemberBinding(_RBACModel):
id: str
tenant_id: str = ""
access_policy_id: str
resource_type: str
resource_id: str = ""
account_id: str
created_at: int = 0
class AccessMatrixItem(_RBACModel):
policy: AccessPolicy | None = None
role_keys: list[str] = Field(default_factory=list)
account_ids: list[str] = Field(default_factory=list)
class AppAccessMatrix(_RBACModel):
app_id: str = ""
items: list[AccessMatrixItem] = Field(default_factory=list)
class DatasetAccessMatrix(_RBACModel):
dataset_id: str = ""
items: list[AccessMatrixItem] = Field(default_factory=list)
class WorkspaceAccessMatrix(_RBACModel):
items: list[AccessMatrixItem] = Field(default_factory=list)
class RoleBindingsResponse(_RBACModel):
data: list[AccessPolicyRoleBinding] = Field(default_factory=list)
class MemberBindingsResponse(_RBACModel):
data: list[AccessPolicyMemberBinding] = Field(default_factory=list)
class MemberRolesResponse(_RBACModel):
account_id: str
roles: list[RBACRole] = Field(default_factory=list)
# ---------- Mutation request models ----------
class RoleMutation(_RBACModel):
"""Payload shared by role create & update.
``type`` defaults to ``workspace`` because that is the only concrete role
type supported by the enterprise backend today (see biz.RBACRoleType).
"""
name: str
role_key: str
description: str = ""
permission_keys: list[str] = Field(default_factory=list)
type: RBACRoleType = RBACRoleType.WORKSPACE
class AccessPolicyCreate(_RBACModel):
name: str
resource_type: RBACResourceType
description: str = ""
permission_keys: list[str] = Field(default_factory=list)
class AccessPolicyUpdate(_RBACModel):
name: str
description: str = ""
permission_keys: list[str] = Field(default_factory=list)
class ReplaceRoleBindings(_RBACModel):
role_keys: list[str] = Field(default_factory=list)
class ReplaceMemberBindings(_RBACModel):
account_ids: list[str] = Field(default_factory=list)
class ListOption(_RBACModel):
page_number: int | None = None
results_per_page: int | None = None
reverse: bool | None = None
def to_params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]:
params: dict[str, Any] = {}
if self.page_number is not None:
params["page_number"] = self.page_number
if self.results_per_page is not None:
params["results_per_page"] = self.results_per_page
if self.reverse is not None:
# httpx renders `True` as the string "True"; we want the inner
# handler to match on the lowercase form it compares against.
params["reverse"] = "true" if self.reverse else "false"
if extra:
params.update({k: v for k, v in extra.items() if v is not None})
return params
_INNER_PREFIX = "/rbac"
def _inner_call(
method: str,
endpoint: str,
*,
tenant_id: str,
account_id: str | None = None,
json: Any | None = None,
params: dict[str, Any] | None = None,
) -> Any:
"""Thin wrapper around `EnterpriseRequest.send_inner_rbac_request`.
Kept as a module-level helper (rather than a nested-class method) so that
unit tests can monkey-patch this single entry point instead of every
individual `Roles.*`, `AccessPolicies.*`, method.
"""
return EnterpriseRequest.send_inner_rbac_request(
method,
endpoint,
tenant_id=tenant_id,
account_id=account_id,
json=json,
params=params,
)
class RBACService:
"""Single entry point grouping every inner RBAC call by feature area.
Each nested class keeps the classmethods tightly scoped to one URL family
so call sites read naturally (e.g. ``RBACService.Roles.create(tenant_id,
account_id, payload)``).
"""
# ------------------------------------------------------------------
# Permission catalog (screenshot 3: 新增/编辑角色 弹窗内的权限列表).
# ------------------------------------------------------------------
class Catalog:
@staticmethod
def workspace(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog",
tenant_id=tenant_id,
account_id=account_id,
)
return PermissionCatalogResponse.model_validate(data or {})
@staticmethod
def app(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog/app",
tenant_id=tenant_id,
account_id=account_id,
)
return PermissionCatalogResponse.model_validate(data or {})
@staticmethod
def dataset(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/role-permissions/catalog/dataset",
tenant_id=tenant_id,
account_id=account_id,
)
return PermissionCatalogResponse.model_validate(data or {})
# ------------------------------------------------------------------
# Role CRUD (Settings > Permissions).
# ------------------------------------------------------------------
class Roles:
@staticmethod
def list(
tenant_id: str,
account_id: str | None = None,
*,
options: ListOption | None = None,
) -> Paginated[RBACRole]:
params = (options or ListOption()).to_params()
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/roles",
tenant_id=tenant_id,
account_id=account_id,
params=params or None,
)
data = data or {}
return Paginated[RBACRole](
data=[RBACRole.model_validate(item) for item in data.get("data") or []],
pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None,
)
@staticmethod
def get(tenant_id: str, account_id: str | None, role_id: str) -> RBACRole:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/roles/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": role_id},
)
return RBACRole.model_validate(data or {})
@staticmethod
def create(tenant_id: str, account_id: str | None, payload: RoleMutation) -> RBACRole:
data = _inner_call(
"POST",
f"{_INNER_PREFIX}/roles",
tenant_id=tenant_id,
account_id=account_id,
json=payload.model_dump(mode="json"),
)
return RBACRole.model_validate(data or {})
@staticmethod
def update(tenant_id: str, account_id: str | None, role_id: str, payload: RoleMutation) -> RBACRole:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/roles/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": role_id},
json=payload.model_dump(mode="json"),
)
return RBACRole.model_validate(data or {})
@staticmethod
def delete(tenant_id: str, account_id: str | None, role_id: str) -> None:
_inner_call(
"DELETE",
f"{_INNER_PREFIX}/roles/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": role_id},
)
# ------------------------------------------------------------------
# Access policies (Settings > Access Rules: create/edit permission sets).
# ------------------------------------------------------------------
class AccessPolicies:
@staticmethod
def list(
tenant_id: str,
account_id: str | None = None,
*,
resource_type: RBACResourceType | str | None = None,
options: ListOption | None = None,
) -> Paginated[AccessPolicy]:
extra: dict[str, Any] = {}
if resource_type is not None:
extra["resource_type"] = (
resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type
)
params = (options or ListOption()).to_params(extra)
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/access-policies",
tenant_id=tenant_id,
account_id=account_id,
params=params or None,
)
data = data or {}
return Paginated[AccessPolicy](
data=[AccessPolicy.model_validate(item) for item in data.get("data") or []],
pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None,
)
@staticmethod
def get(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/access-policies/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": policy_id},
)
return AccessPolicy.model_validate(data or {})
@staticmethod
def create(tenant_id: str, account_id: str | None, payload: AccessPolicyCreate) -> AccessPolicy:
data = _inner_call(
"POST",
f"{_INNER_PREFIX}/access-policies",
tenant_id=tenant_id,
account_id=account_id,
json=payload.model_dump(mode="json"),
)
return AccessPolicy.model_validate(data or {})
@staticmethod
def update(
tenant_id: str,
account_id: str | None,
policy_id: str,
payload: AccessPolicyUpdate,
) -> AccessPolicy:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/access-policies/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": policy_id},
json=payload.model_dump(mode="json"),
)
return AccessPolicy.model_validate(data or {})
@staticmethod
def copy(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy:
data = _inner_call(
"POST",
f"{_INNER_PREFIX}/access-policies/copy",
tenant_id=tenant_id,
account_id=account_id,
params={"id": policy_id},
)
return AccessPolicy.model_validate(data or {})
@staticmethod
def delete(tenant_id: str, account_id: str | None, policy_id: str) -> None:
_inner_call(
"DELETE",
f"{_INNER_PREFIX}/access-policies/item",
tenant_id=tenant_id,
account_id=account_id,
params={"id": policy_id},
)
# ------------------------------------------------------------------
# Per-app access (screenshot 1: App Access Config).
# ------------------------------------------------------------------
class AppAccess:
@staticmethod
def matrix(tenant_id: str, account_id: str | None, app_id: str) -> AppAccessMatrix:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/apps/access-policy",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id},
)
return AppAccessMatrix.model_validate(data or {})
@staticmethod
def list_role_bindings(
tenant_id: str,
account_id: str | None,
app_id: str,
policy_id: str,
) -> RoleBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/apps/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id, "policy_id": policy_id},
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def replace_role_bindings(
tenant_id: str,
account_id: str | None,
app_id: str,
policy_id: str,
payload: ReplaceRoleBindings,
) -> RoleBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/apps/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id, "policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def list_member_bindings(
tenant_id: str,
account_id: str | None,
app_id: str,
policy_id: str,
) -> MemberBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/apps/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id, "policy_id": policy_id},
)
return MemberBindingsResponse.model_validate(data or {})
@staticmethod
def replace_member_bindings(
tenant_id: str,
account_id: str | None,
app_id: str,
policy_id: str,
payload: ReplaceMemberBindings,
) -> MemberBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/apps/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id, "policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return MemberBindingsResponse.model_validate(data or {})
# ------------------------------------------------------------------
# Per-dataset access (screenshot 1: Knowledge Base Access Config).
# ------------------------------------------------------------------
class DatasetAccess:
@staticmethod
def matrix(tenant_id: str, account_id: str | None, dataset_id: str) -> DatasetAccessMatrix:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/datasets/access-policy",
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id},
)
return DatasetAccessMatrix.model_validate(data or {})
@staticmethod
def list_role_bindings(
tenant_id: str,
account_id: str | None,
dataset_id: str,
policy_id: str,
) -> RoleBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/datasets/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id, "policy_id": policy_id},
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def replace_role_bindings(
tenant_id: str,
account_id: str | None,
dataset_id: str,
policy_id: str,
payload: ReplaceRoleBindings,
) -> RoleBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/datasets/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id, "policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def list_member_bindings(
tenant_id: str,
account_id: str | None,
dataset_id: str,
policy_id: str,
) -> MemberBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/datasets/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id, "policy_id": policy_id},
)
return MemberBindingsResponse.model_validate(data or {})
@staticmethod
def replace_member_bindings(
tenant_id: str,
account_id: str | None,
dataset_id: str,
policy_id: str,
payload: ReplaceMemberBindings,
) -> MemberBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/datasets/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id, "policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return MemberBindingsResponse.model_validate(data or {})
# ------------------------------------------------------------------
# Workspace-level access (screenshot 2: Settings > Access Rules).
# ------------------------------------------------------------------
class WorkspaceAccess:
@staticmethod
def app_matrix(tenant_id: str, account_id: str | None = None) -> WorkspaceAccessMatrix:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/apps/access-policy",
tenant_id=tenant_id,
account_id=account_id,
)
return WorkspaceAccessMatrix.model_validate(data or {})
@staticmethod
def dataset_matrix(tenant_id: str, account_id: str | None = None) -> WorkspaceAccessMatrix:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/datasets/access-policy",
tenant_id=tenant_id,
account_id=account_id,
)
return WorkspaceAccessMatrix.model_validate(data or {})
@staticmethod
def list_app_role_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
) -> RoleBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def replace_app_role_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
payload: ReplaceRoleBindings,
) -> RoleBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def list_app_member_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
) -> MemberBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
)
return MemberBindingsResponse.model_validate(data or {})
@staticmethod
def replace_app_member_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
payload: ReplaceMemberBindings,
) -> MemberBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return MemberBindingsResponse.model_validate(data or {})
@staticmethod
def list_dataset_role_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
) -> RoleBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def replace_dataset_role_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
payload: ReplaceRoleBindings,
) -> RoleBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return RoleBindingsResponse.model_validate(data or {})
@staticmethod
def list_dataset_member_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
) -> MemberBindingsResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
)
return MemberBindingsResponse.model_validate(data or {})
@staticmethod
def replace_dataset_member_bindings(
tenant_id: str,
account_id: str | None,
policy_id: str,
payload: ReplaceMemberBindings,
) -> MemberBindingsResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings",
tenant_id=tenant_id,
account_id=account_id,
params={"policy_id": policy_id},
json=payload.model_dump(mode="json"),
)
return MemberBindingsResponse.model_validate(data or {})
# ------------------------------------------------------------------
# Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles).
# ------------------------------------------------------------------
class MemberRoles:
@staticmethod
def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/members/rbac-roles",
tenant_id=tenant_id,
account_id=account_id,
params={"account_id": member_account_id},
)
return MemberRolesResponse.model_validate(data or {})
@staticmethod
def replace(
tenant_id: str,
account_id: str | None,
member_account_id: str,
role_keys: list[str],
) -> MemberRolesResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/members/rbac-roles",
tenant_id=tenant_id,
account_id=account_id,
params={"account_id": member_account_id},
json={"role_keys": role_keys},
)
return MemberRolesResponse.model_validate(data or {})

View File

@ -0,0 +1,124 @@
"""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 three Flask-layer concerns the service layer cannot
exercise:
* ``enterprise_only`` rejects community-edition calls with 403 (it is the
outermost decorator, so it fires before any auth middleware).
* ``_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
from unittest.mock import patch
import pytest
from flask import Flask
from pydantic import ValidationError
from werkzeug.exceptions import Forbidden, 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 TestEnterpriseGate:
"""``enterprise_only`` is the outermost decorator on every resource, so we
can exercise it directly no auth stubs required.
"""
def test_catalog_forbidden_when_disabled(self, app):
with app.test_request_context("/workspaces/current/rbac/role-permissions/catalog"), _enabled(False):
with pytest.raises(Forbidden):
rbac_mod.RBACWorkspaceCatalogApi().get()
def test_roles_post_forbidden_when_disabled(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles", method="POST", json={}),
_enabled(False),
):
with pytest.raises(Forbidden):
rbac_mod.RBACRolesApi().post()
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_and_key(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",
"role_key": "workspace.owner",
"description": "full access",
"permission_keys": ["workspace.member.manage"],
}
)
mutation = payload.to_mutation()
assert mutation.role_key == "workspace.owner"
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_role_bindings_defaults_empty(self):
parsed = rbac_mod._ReplaceRoleBindingsRequest.model_validate({})
assert parsed.role_keys == []
class TestDumpHelper:
def test_dump_returns_plain_dict(self):
role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", role_key="workspace.owner", name="Owner")
dumped = rbac_mod._dump(role)
assert isinstance(dumped, dict)
assert dumped["role_key"] == "workspace.owner"

View File

@ -0,0 +1,315 @@
"""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",
"role_key": "workspace.owner",
"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
assert out.data[0].role_key == "workspace.owner"
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_get_passes_id_query_param(self, mock_send: MagicMock):
mock_send.return_value = {
"id": "role-1",
"type": "workspace",
"role_key": "workspace.owner",
"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",
"role_key": "workspace.owner",
"name": "Owner",
}
payload = svc.RoleMutation(
name="Owner",
role_key="workspace.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",
"role_key": "workspace.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",
"role_key": "workspace.owner",
"name": "Owner",
}
payload = svc.RoleMutation(name="Owner", role_key="workspace.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["role_key"] == "workspace.owner"
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
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_replace_role_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceRoleBindings(role_keys=["workspace.owner"])
svc.RBACService.AppAccess.replace_role_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/role-bindings"
assert call.params == {"app_id": "app-1", "policy_id": "policy-1"}
assert call.json == {"role_keys": ["workspace.owner"]}
def test_dataset_replace_member_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceMemberBindings(account_ids=["acct-2"])
svc.RBACService.DatasetAccess.replace_member_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/member-bindings"
assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"}
assert call.json == {"account_ids": ["acct-2"]}
class TestWorkspaceAccess:
def test_app_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"items": []}
svc.RBACService.WorkspaceAccess.app_matrix("tenant-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/workspace/apps/access-policy"
assert call.params is None
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_dataset_replace_role_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceRoleBindings(role_keys=["workspace.editor"])
svc.RBACService.WorkspaceAccess.replace_dataset_role_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/role-bindings"
assert call.params == {"policy_id": "policy-1"}
assert call.json == {"role_keys": ["workspace.editor"]}
class TestMemberRoles:
def test_get(self, mock_send: MagicMock):
mock_send.return_value = {
"account_id": "acct-2",
"roles": [
{
"id": "role-1",
"type": "workspace",
"role_key": "workspace.member",
"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].role_key == "workspace.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_keys=["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_keys": ["workspace.owner", "workspace.editor"]}
class TestListOption:
def test_empty_produces_empty_params(self):
assert svc.ListOption().to_params() == {}
def test_reverse_serialises_as_lowercase_bool(self):
assert svc.ListOption(reverse=False).to_params()["reverse"] == "false"
assert svc.ListOption(reverse=True).to_params()["reverse"] == "true"
def test_extra_overrides_merge(self):
assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == {
"page_number": 1,
"resource_type": "app",
}

View File

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

View File

@ -12,6 +12,8 @@ import {
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import * as React from 'react'
@ -73,51 +75,58 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
if (isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation) {
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
})
}
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
...(isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation
? [{
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: EvaluationIcon,
selectedIcon: EvaluationIcon,
}]
: []
),
...(isCurrentWorkspaceEditor
? [{
name: 'Access Config',
href: `/app/${appId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
}]
: []
),
]
return navConfig
}, [canAccessSnippetsAndEvaluation, t])

View File

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

View File

@ -9,6 +9,8 @@ import {
RiFileTextLine,
RiFocus2Fill,
RiFocus2Line,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -90,6 +92,13 @@ 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') {

View File

@ -0,0 +1,120 @@
'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

View File

@ -0,0 +1,103 @@
'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

View File

@ -0,0 +1,74 @@
'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

View File

@ -0,0 +1,108 @@
'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

View File

@ -68,6 +68,9 @@ 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
@ -86,6 +89,7 @@ type AppCardOperationsMenuProps = {
onSwitch: () => void
onDelete: () => void
onAccessControl: () => void
onAccessConfig: () => void
}
const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
@ -99,6 +103,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
onSwitch,
onDelete,
onAccessControl,
onAccessConfig,
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
@ -167,6 +172,10 @@ 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"
@ -217,6 +226,7 @@ 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()
@ -288,6 +298,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
})
}, [])
const handleShowAccessConfig = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowAccessConfig(true)
})
}, [])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@ -550,6 +567,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleShowAccessConfig}
/>
)
: (
@ -564,6 +582,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleShowAccessConfig}
/>
)}
</DropdownMenuContent>
@ -670,6 +689,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
{showAccessControl && (
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
)}
{showAccessConfig && (
<AppAccessConfigModal
open
app={app}
onClose={() => setShowAccessConfig(false)}
/>
)}
</>
)
}

View File

@ -0,0 +1,83 @@
'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

View File

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

View File

@ -71,10 +71,12 @@ 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(),
}
@ -209,6 +211,7 @@ describe('DatasetCardModals', () => {
modalState={{
showRenameModal: true,
showConfirmDelete: true,
showAccessConfig: false,
confirmMessage: 'Delete this dataset?',
}}
/>,

View File

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

View File

@ -10,11 +10,17 @@ 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
}
@ -23,6 +29,7 @@ type DatasetCardModalsProps = {
modalState: ModalState
onCloseRename: () => void
onCloseConfirm: () => void
onCloseAccessConfig: () => void
onConfirmDelete: () => void
onSuccess?: () => void
}
@ -32,6 +39,7 @@ const DatasetCardModals = ({
modalState,
onCloseRename,
onCloseConfirm,
onCloseAccessConfig,
onConfirmDelete,
onSuccess,
}: DatasetCardModalsProps) => {
@ -47,6 +55,13 @@ 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">

View File

@ -14,6 +14,7 @@ type OperationsDropdownProps = {
openRenameModal: () => void
handleExportPipeline: (include?: boolean) => void
detectIsUsedByApp: () => void
openAccessConfig: () => void
}
const OperationsDropdown = ({
@ -22,6 +23,7 @@ const OperationsDropdown = ({
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
openAccessConfig,
}: OperationsDropdownProps) => {
const [open, setOpen] = React.useState(false)
@ -58,6 +60,7 @@ const OperationsDropdown = ({
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,108 @@
'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

View File

@ -10,6 +10,7 @@ import { downloadBlob } from '@/utils/download'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
showAccessConfig: boolean
confirmMessage: string
}
@ -30,6 +31,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
const [modalState, setModalState] = useState<ModalState>({
showRenameModal: false,
showConfirmDelete: false,
showAccessConfig: false,
confirmMessage: '',
})
@ -49,6 +51,14 @@ 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()
@ -122,6 +132,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
openRenameModal,
closeRenameModal,
closeConfirmDelete,
openAccessConfig,
closeAccessConfig,
// Export state
exporting,

View File

@ -37,6 +37,8 @@ const DatasetCard = ({
openRenameModal,
closeRenameModal,
closeConfirmDelete,
openAccessConfig,
closeAccessConfig,
handleExportPipeline,
detectIsUsedByApp,
onConfirmDelete,
@ -88,6 +90,7 @@ const DatasetCard = ({
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
/>
</div>
<DatasetCardModals
@ -95,6 +98,7 @@ const DatasetCard = ({
modalState={modalState}
onCloseRename={closeRenameModal}
onCloseConfirm={closeConfirmDelete}
onCloseAccessConfig={closeAccessConfig}
onConfirmDelete={onConfirmDelete}
onSuccess={onSuccess}
/>

View File

@ -11,6 +11,7 @@ type OperationsProps = {
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
openAccessConfig: () => void
onClose?: () => void
}
@ -20,6 +21,7 @@ const Operations = ({
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
openAccessConfig,
onClose,
}: OperationsProps) => {
const { t } = useTranslation()
@ -39,23 +41,32 @@ const Operations = ({
detectIsUsedByApp()
}
const handleAccessConfig = () => {
onClose?.()
openAccessConfig()
}
return (
<>
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
{showExportPipeline && (
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
<span aria-hidden className="mr-1 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="i-ri-delete-bin-line size-4" />
<span aria-hidden className="mr-1 i-ri-delete-bin-line size-4" />
{t('operation.delete', { ns: 'common' })}
</DropdownMenuItem>
</>

View File

@ -0,0 +1,69 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
export type AccessRuleRowMenuProps = {
onEdit?: () => void
onCopy?: () => void
onDelete?: () => void
}
const AccessRuleRowMenu = ({
onEdit,
onCopy,
onDelete,
}: AccessRuleRowMenuProps) => {
const [open, setOpen] = useState(false)
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={onCopy}
>
Copy
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="system-sm-semibold"
onClick={onDelete}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AccessRuleRowMenu

View File

@ -0,0 +1,86 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback } from 'react'
import AccessRuleRowMenu from './access-rule-row-menu'
import RoleTag from './role-tag'
export type AssignedRole = {
id: string
name: string
}
export type AccessRule = {
id: string
name: string
description: string
assignedRoles: AssignedRole[]
permissions: string[]
}
export type AccessRuleRowProps = {
rule: AccessRule
className?: string
showMenu?: boolean
onEdit?: (rule: AccessRule) => void
onCopy?: (rule: AccessRule) => void
onDelete?: (rule: AccessRule) => void
onAddRole?: (rule: AccessRule) => void
onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void
}
const AccessRuleRow = ({
rule,
className,
showMenu = true,
onEdit,
onCopy,
onDelete,
onAddRole,
onRemoveRole,
}: AccessRuleRowProps) => {
const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule])
const handleCopy = useCallback(() => onCopy?.(rule), [onCopy, rule])
const handleDelete = useCallback(() => onDelete?.(rule), [onDelete, rule])
const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule])
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">
{rule.name}
</div>
<p className="mt-0.5 system-xs-regular text-text-tertiary">
{rule.description}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{rule.assignedRoles.map(role => (
<RoleTag
key={role.id}
label={role.name}
onRemove={onRemoveRole ? () => onRemoveRole(rule, role) : undefined}
/>
))}
<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 ${rule.name}`}
>
<span aria-hidden className="i-ri-add-line h-3 w-3" />
Add
</button>
</div>
</div>
{showMenu && (
<AccessRuleRowMenu
onEdit={handleEdit}
onCopy={handleCopy}
onDelete={handleDelete}
/>
)}
</div>
)
}
export default memo(AccessRuleRow)

View File

@ -0,0 +1,62 @@
'use client'
import type { AccessRule, AssignedRole } from './access-rule-row'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
import AccessRuleRow from './access-rule-row'
export type AccessRuleSectionProps = {
title: string
rules: AccessRule[]
createButtonLabel: string
onCreate?: () => void
onEditRule?: (rule: AccessRule) => void
onCopyRule?: (rule: AccessRule) => void
onDeleteRule?: (rule: AccessRule) => void
onAddRole?: (rule: AccessRule) => void
onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void
className?: string
}
const AccessRuleSection = ({
title,
rules,
createButtonLabel,
onCreate,
onEditRule,
onCopyRule,
onDeleteRule,
onAddRole,
onRemoveRole,
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.id}
rule={rule}
className={cn(index > 0 && 'border-t border-divider-subtle')}
onEdit={onEditRule}
onCopy={onCopyRule}
onDelete={onDeleteRule}
onAddRole={onAddRole}
onRemoveRole={onRemoveRole}
/>
))}
</div>
</section>
)
}
export default memo(AccessRuleSection)

View File

@ -0,0 +1,361 @@
'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 { useMembers } from '@/service/use-common'
export type AssignableRoleOption = {
id: string
name: string
description?: string
}
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 & {
open: boolean
}
const TABS: Array<{ key: TabKey, label: string }> = [
{ key: 'roles', label: 'ROLES' },
{ key: 'members', label: 'MEMBERS' },
]
// TODO: replace with roles fetched from the permissions API once available.
const MOCK_ROLE_OPTIONS: AssignableRoleOption[] = [
{ id: 'admin', name: 'Admin', description: 'Full workspace management' },
{ id: 'editor', name: 'Editor', description: 'Create and edit resources' },
{ id: 'member', name: 'Member', description: 'Basic access' },
{ id: 'auditor', name: 'Auditor', description: 'View logs and audit trails' },
{ id: 'tester', name: 'Tester', description: 'Test in sandbox' },
]
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 { data: membersData, isLoading: membersLoading } = useMembers()
const roles = MOCK_ROLE_OPTIONS
const members = useMemo<AssignableMemberOption[]>(() => {
const accounts = membersData?.accounts ?? []
return accounts
.filter(account => account.status !== 'banned' && account.status !== 'closed')
.map(toMemberOption)
}, [membersData])
const [activeTab, setActiveTab] = useState<TabKey>('roles')
const [keyword, setKeyword] = useState('')
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
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' && (
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>
{role.description && (
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.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 = ({
open,
ruleName,
initialRoleIds,
initialMemberIds,
onClose,
onSubmit,
}: AddRuleTargetsModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<AddRuleTargetsModalBody
ruleName={ruleName}
initialRoleIds={initialRoleIds}
initialMemberIds={initialMemberIds}
onClose={onClose}
onSubmit={onSubmit}
/>
</Dialog>
)
}
export default AddRuleTargetsModal

View File

@ -0,0 +1,260 @@
'use client'
import type { AccessRule } from './access-rule-row'
import type { PermissionSetFormValues, PermissionSetModalMode } from './permission-set-modal'
import type { ResourceType } from './permission-set-modal/permissions-data'
import { useCallback, useState } from 'react'
import AccessRuleSection from './access-rule-section'
import AddRuleTargetsModal from './add-rule-targets-modal'
import PermissionSetModal from './permission-set-modal'
const 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: [
'app.editing_and_layout',
'app.test_and_debug',
'app.delete',
'app.import_export_dsl',
'app.release_version_management',
'app.annotation_management',
'app.api_management.toggle',
'app.api_management.create_key',
'app.api_management.delete_key',
],
},
{
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: [
'app.editing_and_layout',
'app.test_and_debug',
'app.release_version_management',
],
},
{
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: [
'app.test_and_debug',
],
},
{
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: [],
},
]
const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [
{
id: 'kb-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete apps, 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: [
'kb.view',
'kb.edit_configuration',
'kb.manage_documents.add',
'kb.manage_documents.delete',
'kb.manage_documents.download',
'kb.import_export_pipeline',
'kb.pipeline_publishing_versioning',
'kb.delete',
],
},
{
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: [
'kb.edit_configuration',
'kb.manage_documents.add',
'kb.manage_documents.delete',
'kb.manage_documents.download',
],
},
{
id: 'kb-can-view',
name: 'Can view',
description: 'View knowledge base sources and logs. Cannot modify content.',
assignedRoles: [
{ id: 'member', name: 'Member' },
],
permissions: ['kb.view'],
},
{
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: ['kb.view'],
},
]
type PermissionSetModalState = {
mode: PermissionSetModalMode
resourceType: ResourceType
initialValues?: PermissionSetFormValues
}
const AccessRulesPage = () => {
const [addingRule, setAddingRule] = useState<AccessRule | null>(null)
const [permissionSetModalState, setPermissionSetModalState]
= useState<PermissionSetModalState | null>(null)
const closeAddModal = useCallback(() => {
setAddingRule(null)
}, [])
const closePermissionSetModal = useCallback(() => {
setPermissionSetModalState(null)
}, [])
const handleAddRole = useCallback((rule: AccessRule) => {
setAddingRule(rule)
}, [])
const handleAddSubmit = useCallback(
(_selection: { roleIds: string[], memberIds: string[] }) => {
// TODO: wire up to API when backend is ready.
},
[],
)
const handleCreate = useCallback((resourceType: ResourceType) => {
setPermissionSetModalState({ mode: 'create', resourceType })
}, [])
const handleEdit = useCallback(
(resourceType: ResourceType, rule: AccessRule) => {
setPermissionSetModalState({
mode: 'edit',
resourceType,
initialValues: {
name: rule.name,
description: rule.description,
permissions: rule.permissions,
},
})
},
[],
)
const handlePermissionSetSubmit = useCallback(
(_values: PermissionSetFormValues) => {
// TODO: wire up to API when backend is ready.
},
[],
)
const noop = useCallback(() => {
// TODO: wire up to API when backend is ready.
}, [])
const createApp = useCallback(() => handleCreate('app'), [handleCreate])
const createKb = useCallback(() => handleCreate('knowledge_base'), [handleCreate])
const editApp = useCallback(
(rule: AccessRule) => handleEdit('app', rule),
[handleEdit],
)
const editKb = useCallback(
(rule: AccessRule) => handleEdit('knowledge_base', rule),
[handleEdit],
)
return (
<>
<div className="flex flex-col gap-6">
<AccessRuleSection
title="App Access Rules"
rules={APP_ACCESS_RULES}
createButtonLabel="Create App permission set"
onCreate={createApp}
onEditRule={editApp}
onCopyRule={noop}
onDeleteRule={noop}
onAddRole={handleAddRole}
onRemoveRole={noop}
/>
<AccessRuleSection
title="Knowledge Base Access Rules"
rules={KNOWLEDGE_BASE_ACCESS_RULES}
createButtonLabel="Create KB permission set"
onCreate={createKb}
onEditRule={editKb}
onCopyRule={noop}
onDeleteRule={noop}
onAddRole={handleAddRole}
onRemoveRole={noop}
/>
</div>
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
initialMemberIds={[]}
onClose={closeAddModal}
onSubmit={handleAddSubmit}
/>
)}
{permissionSetModalState && (
<PermissionSetModal
open
mode={permissionSetModalState.mode}
resourceType={permissionSetModalState.resourceType}
initialValues={permissionSetModalState.initialValues}
onClose={closePermissionSetModal}
onSubmit={handlePermissionSetSubmit}
/>
)}
</>
)
}
export default AccessRulesPage

View File

@ -0,0 +1,225 @@
'use client'
import type { ResourceType } from './permissions-data'
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 { useMemo, useState } from 'react'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionPicker from './permission-picker'
import { getPermissionMap } from './permissions-data'
export type PermissionSetModalMode = 'create' | 'edit'
export type PermissionSetFormValues = {
name: string
description: string
permissions: string[]
}
export type PermissionSetModalProps = {
open: boolean
mode: PermissionSetModalMode
resourceType: ResourceType
initialValues?: Partial<PermissionSetFormValues>
onClose: () => void
onSubmit: (values: PermissionSetFormValues) => void
}
const RESOURCE_LABEL: Record<ResourceType, string> = {
app: 'App',
knowledge_base: 'Knowledge Base',
}
const buildTitle = (mode: PermissionSetModalMode, resource: ResourceType): string => {
const verb = mode === 'create' ? 'Create' : 'Edit'
return `${verb} ${RESOURCE_LABEL[resource]} permission set`
}
const buildDescription = (mode: PermissionSetModalMode, resource: ResourceType): 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 [permissions, setPermissions] = useState<string[]>(initialValues?.permissions ?? [])
const permissionMap = useMemo(() => getPermissionMap(resourceType), [resourceType])
const trimmedName = name.trim()
const canSubmit = trimmedName.length > 0
const handleConfirm = () => {
if (!canSubmit)
return
onSubmit({
name: trimmedName,
description: description.trim(),
permissions,
})
onClose()
}
const handleRemovePermission = (id: string) => {
setPermissions(prev => prev.filter(p => p !== id))
}
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>
{permissions.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{permissions.map((id) => {
const p = permissionMap[id]
if (!p)
return null
return (
<span
key={id}
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(id)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
<PermissionPicker
resourceType={resourceType}
value={permissions}
onChange={setPermissions}
/>
</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

View File

@ -0,0 +1,197 @@
'use client'
import type { PermissionGroup, ResourceType } from './permissions-data'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import {
filterPermissionNodes,
PERMISSION_NODES_BY_RESOURCE,
} from './permissions-data'
type PermissionPickerProps = {
resourceType: ResourceType
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 nodes = PERMISSION_NODES_BY_RESOURCE[resourceType]
const filtered = useMemo(
() => filterPermissionNodes(nodes, search),
[nodes, search],
)
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.items.reduce(
(acc, i) => acc + (selectedSet.has(i.id) ? 1 : 0),
0,
)
return {
allChecked: checkedCount > 0 && checkedCount === group.items.length,
indeterminate: checkedCount > 0 && checkedCount < group.items.length,
}
}
const toggleGroup = (group: PermissionGroup) => {
const { allChecked, indeterminate } = getGroupState(group)
const ids = group.items.map(i => i.id)
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"
>
{filtered.length === 0 && (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No permissions found
</div>
)}
{filtered.map((node) => {
if (node.kind === 'leaf') {
const checked = selectedSet.has(node.leaf.id)
return (
<button
key={node.leaf.id}
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => togglePermission(node.leaf.id)}
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"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{node.leaf.name}
</span>
</button>
)
}
const { allChecked, indeterminate } = getGroupState(node.group)
return (
<div key={node.group.id} className="flex flex-col">
<button
type="button"
role="menuitemcheckbox"
aria-checked={allChecked ? true : indeterminate ? 'mixed' : false}
onClick={() => toggleGroup(node.group)}
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"
>
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
className="pointer-events-none"
/>
<span className="system-sm-regular text-text-secondary">
{node.group.label}
</span>
</button>
{node.group.items.map((item) => {
const checked = selectedSet.has(item.id)
return (
<button
key={item.id}
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => togglePermission(item.id)}
className={cn(
'mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg pr-2 pl-7 text-left outline-hidden hover:bg-state-base-hover',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{item.name}
</span>
</button>
)
})}
</div>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default PermissionPicker

View File

@ -0,0 +1,103 @@
export type PermissionLeaf = {
id: string
name: string
}
export type PermissionGroup = {
id: string
label: string
items: PermissionLeaf[]
}
export type PermissionNode
= | { kind: 'leaf', leaf: PermissionLeaf }
| { kind: 'group', group: PermissionGroup }
export type ResourceType = 'app' | 'knowledge_base'
const APP_PERMISSION_NODES: PermissionNode[] = [
{ kind: 'leaf', leaf: { id: 'app.editing_and_layout', name: 'Editing and layout app' } },
{ kind: 'leaf', leaf: { id: 'app.test_and_debug', name: 'Test and debug app' } },
{ kind: 'leaf', leaf: { id: 'app.delete', name: 'Delete app' } },
{ kind: 'leaf', leaf: { id: 'app.import_export_dsl', name: 'Import and Export DSL' } },
{ kind: 'leaf', leaf: { id: 'app.release_version_management', name: 'Application Release and Version Management' } },
{ kind: 'leaf', leaf: { id: 'app.annotation_management', name: 'Annotation Management' } },
{
kind: 'group',
group: {
id: 'app.api_management',
label: 'API Management',
items: [
{ id: 'app.api_management.toggle', name: 'Enable/Disable API Access' },
{ id: 'app.api_management.create_key', name: 'Create App API Key' },
{ id: 'app.api_management.delete_key', name: 'Delete App API Key' },
],
},
},
]
const KNOWLEDGE_BASE_PERMISSION_NODES: PermissionNode[] = [
{ kind: 'leaf', leaf: { id: 'kb.view', name: 'View Knowledge Base' } },
{ kind: 'leaf', leaf: { id: 'kb.edit_configuration', name: 'Edit Knowledge Base Configuration' } },
{
kind: 'group',
group: {
id: 'kb.manage_documents',
label: 'Managing Knowledge Base Documents',
items: [
{ id: 'kb.manage_documents.add', name: 'Add Document' },
{ id: 'kb.manage_documents.delete', name: 'Delete Document' },
{ id: 'kb.manage_documents.download', name: 'Download Document' },
],
},
},
{ kind: 'leaf', leaf: { id: 'kb.import_export_pipeline', name: 'Import Pipeline from DSL / Export Knowledge Pipeline DSL' } },
{ kind: 'leaf', leaf: { id: 'kb.pipeline_publishing_versioning', name: 'Knowledge Base Pipeline Publishing and Version Management' } },
{ kind: 'leaf', leaf: { id: 'kb.delete', name: 'Delete Knowledge Base' } },
]
export const PERMISSION_NODES_BY_RESOURCE: Record<ResourceType, PermissionNode[]> = {
app: APP_PERMISSION_NODES,
knowledge_base: KNOWLEDGE_BASE_PERMISSION_NODES,
}
export const flattenPermissionNodes = (nodes: PermissionNode[]): PermissionLeaf[] => {
const out: PermissionLeaf[] = []
for (const node of nodes) {
if (node.kind === 'leaf')
out.push(node.leaf)
else
out.push(...node.group.items)
}
return out
}
export const getPermissionMap = (resourceType: ResourceType): Record<string, PermissionLeaf> => {
const flat = flattenPermissionNodes(PERMISSION_NODES_BY_RESOURCE[resourceType])
return Object.fromEntries(flat.map(p => [p.id, p]))
}
export const filterPermissionNodes = (
nodes: PermissionNode[],
keyword: string,
): PermissionNode[] => {
const q = keyword.trim().toLowerCase()
if (!q)
return nodes
const out: PermissionNode[] = []
for (const node of nodes) {
if (node.kind === 'leaf') {
if (node.leaf.name.toLowerCase().includes(q))
out.push(node)
}
else {
const matchedItems = node.group.items.filter(i => i.name.toLowerCase().includes(q))
const groupMatch = node.group.label.toLowerCase().includes(q)
if (groupMatch)
out.push(node)
else if (matchedItems.length > 0)
out.push({ kind: 'group', group: { ...node.group, items: matchedItems } })
}
}
return out
}

View File

@ -0,0 +1,39 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
export type RoleTagProps = {
label: string
onRemove?: () => void
className?: string
}
const RoleTag = ({ label, 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()
}}
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)

View File

@ -3,6 +3,8 @@ 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',

View File

@ -16,12 +16,14 @@ 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
@ -49,7 +51,7 @@ export default function AccountSetting({
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const activeMenu = activeTab
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { enableBilling, enableReplaceWebAppLogo, enableAccessControl } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const workplaceGroupItems: GroupItem[] = (() => {
@ -71,6 +73,23 @@ 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,
@ -228,6 +247,8 @@ 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 />}

View File

@ -51,11 +51,19 @@ vi.mock('../invited-modal', () => ({
</div>
),
}))
vi.mock('../operation', () => ({
default: () => <div>Member Operation</div>,
vi.mock('../role-badges', () => ({
default: ({ roles }: { roles: string[] }) => (
<div data-testid="role-badges">{roles.join(',')}</div>
),
}))
vi.mock('../operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
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('../transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
@ -65,6 +73,16 @@ 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>,
}))
@ -360,6 +378,52 @@ 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,

View File

@ -0,0 +1,218 @@
'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

View File

@ -1,9 +1,9 @@
'use client'
import type { InvitationResult } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import type { InvitationResult, Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
@ -11,7 +11,6 @@ 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'
@ -19,8 +18,8 @@ import EditWorkspaceModal from './edit-workspace-modal'
import InviteButton from './invite-button'
import InviteModal from './invite-modal'
import InvitedModal from './invited-modal'
import Operation from './operation'
import TransferOwnership from './operation/transfer-ownership'
import MemberDetailsModal from './member-details-modal'
import MemberRow from './member-row'
import TransferOwnershipModal from './transfer-ownership-modal'
const MembersPage = () => {
@ -37,7 +36,6 @@ 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)
@ -47,13 +45,30 @@ 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-l-[0.5px] border-t-[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 uppercase text-shadow-shadow-1 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 text-text-secondary system-md-semibold">
@ -117,43 +132,25 @@ const MembersPage = () => {
</div>
<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 text-text-tertiary system-xs-medium-uppercase">{t('members.name', { ns: 'common' })}</div>
<div className="w-[104px] shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('members.lastActive', { ns: 'common' })}</div>
<div className="w-[96px] shrink-0 px-3 text-text-tertiary system-xs-medium-uppercase">{t('members.role', { ns: 'common' })}</div>
<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>
<div className="relative min-w-[480px]">
{
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="text-text-secondary system-sm-medium">
{account.name}
{account.status === 'pending' && <span className="ml-1 text-text-warning system-xs-medium">{t('members.pending', { ns: 'common' })}</span>}
{userProfile.email === account.email && <span className="text-text-tertiary system-xs-regular">{t('members.you', { ns: 'common' })}</span>}
</div>
<div className="text-text-tertiary system-xs-regular">{account.email}</div>
</div>
</div>
<div className="flex w-[104px] shrink-0 items-center py-2 text-text-secondary system-sm-regular">{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 text-text-secondary system-sm-regular">{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
)}
{!isCurrentWorkspaceOwner && (
<div className="px-3 text-text-secondary system-sm-regular">{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div>
</div>
))
}
{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}
/>
))}
</div>
</div>
</div>
@ -191,6 +188,19 @@ 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}
/>
)}
</>
)
}

View File

@ -0,0 +1,145 @@
'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)

View File

@ -0,0 +1,102 @@
'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)

View File

@ -0,0 +1,36 @@
// 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] ?? []
}

View File

@ -0,0 +1,134 @@
'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)

View File

@ -0,0 +1,117 @@
'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)

View File

@ -0,0 +1,53 @@
'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)

View File

@ -0,0 +1,186 @@
'use client'
import type { Role, RoleListGroup } from './role-list'
import type { RoleModalMode } from './role-modal'
import { Button } from '@langgenius/dify-ui/button'
import { useCallback, useState } from 'react'
import RoleList from './role-list'
import RoleModal from './role-modal'
const MOCK_ROLE_GROUPS: RoleListGroup[] = [
{
id: 'system',
type: 'system',
title: 'System roles',
items: [
{
id: 'owner',
name: 'Owner',
description: 'Full access to all workspace features and settings.',
permissions: [
'manage_model_providers',
'manage_members',
'manage_roles_permissions',
'manage_billing',
'manage_data_sources',
'manage_api_extensions',
'create_apps',
'view_all_apps',
'delete_any_app',
'create_knowledge_bases',
'view_all_knowledge_bases',
'delete_any_knowledge_base',
'view_all_app_logs',
'cross_app_log_access',
'view_sensitive_fields',
'install_plugins',
'uninstall_plugins',
],
},
{
id: 'admin',
name: 'Admin',
description: 'Manage apps, update settings, manage members and permissions.',
permissions: [
'manage_members',
'manage_roles_permissions',
'manage_data_sources',
'create_apps',
'view_all_apps',
'create_knowledge_bases',
'view_all_knowledge_bases',
],
},
{
id: 'editor',
name: 'Editor',
description: 'Create and edit resources (knowledge bases, apps, plugins) without workspace settings access.',
permissions: [
'create_apps',
'view_all_apps',
'create_knowledge_bases',
'view_all_knowledge_bases',
'install_plugins',
],
},
{
id: 'member',
name: 'Member',
description: 'Limited permissions within the workspace.',
permissions: ['view_all_apps', 'view_all_knowledge_bases'],
},
{
id: 'none',
name: 'No Permission',
description: 'Default role with no permissions assigned.',
permissions: [],
},
],
},
{
id: 'custom',
type: 'custom',
title: 'Custom roles',
items: [
{
id: 'executive',
name: 'Executive',
description: 'Unrestricted access to all workspace operations.',
permissions: [
'manage_model_providers',
'manage_members',
'manage_roles_permissions',
'manage_billing',
'create_apps',
'view_all_apps',
'create_knowledge_bases',
'view_all_knowledge_bases',
],
},
{
id: 'employee',
name: 'Employee',
description: 'Access to payroll bot and internal project knowledge bases.',
permissions: ['view_all_apps', 'view_all_knowledge_bases'],
},
{
id: 'partner',
name: 'Partner',
description: 'View external-facing apps: product info, feedback forms, and visitor registration.',
permissions: ['view_all_apps'],
},
],
},
]
type ModalState = {
mode: RoleModalMode
role?: Role
} | null
const PermissionsPage = () => {
const [modalState, setModalState] = useState<ModalState>(null)
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: { name: string, description: string, permissions: string[] }) => {
// TODO: wire up to API when backend is ready
},
[],
)
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={MOCK_ROLE_GROUPS}
onView={handleView}
onEdit={handleEdit}
/>
</div>
{modalState && (
<RoleModal
mode={modalState?.mode ?? 'create'}
open
role={modalState?.role}
onClose={closeModal}
onSubmit={handleSubmit}
/>
)}
</>
)
}
export default PermissionsPage

View File

@ -0,0 +1,70 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import Row from './row'
export type Role = {
id: string
name: string
description: string
permissions?: string[]
}
export type RoleType = 'system' | 'custom'
export type RoleListGroup = {
id: string
type: RoleType
title: string
items: Role[]
}
export type RoleListProps = {
groups: RoleListGroup[]
className?: string
onView?: (role: Role) => void
onEdit?: (role: Role) => void
onDelete?: (role: Role) => void
}
const RoleList = ({
groups,
className,
onView,
onEdit,
onDelete,
}: 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}
roleType={group.type}
role={row}
onView={onView}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</section>
))}
</div>
)
}
export default RoleList

View File

@ -0,0 +1,74 @@
'use client'
import type { Role, RoleType } from '.'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useCallback, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
type RowMenuProps = {
roleType: RoleType
role: Role
onView?: (role: Role) => void
onEdit?: (role: Role) => void
onDelete?: (role: Role) => void
}
const RowMenu = ({
roleType,
role,
onView,
onEdit,
onDelete,
}: RowMenuProps) => {
const [open, setOpen] = useState(false)
const handleView = useCallback(() => onView?.(role), [onView, role])
const handleEdit = useCallback(() => onEdit?.(role), [onEdit, role])
const handleDuplicate = useCallback(() => {
// TODO: wire up to API when backend is ready
}, [])
const handleDelete = useCallback(() => onDelete?.(role), [onDelete, role])
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]">
{
roleType === 'system' && (
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleView}>
View
</DropdownMenuItem>
)
}
{
roleType === '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

View File

@ -0,0 +1,53 @@
import type { Role, RoleType } from '.'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
import RowMenu from './row-menu'
type RowProps = {
className?: string
name: string
description: string
roleType: RoleType
role: Role
onView?: (role: Role) => void
onEdit?: (role: Role) => void
onDelete?: (role: Role) => void
}
const Row = ({
className,
name,
description,
roleType,
role,
onView,
onEdit,
onDelete,
}: 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}
</p>
</div>
<RowMenu
roleType={roleType}
role={role}
onView={onView}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
)
}
export default memo(Row)

View File

@ -0,0 +1,151 @@
'use client'
import type { Role } from '../role-list'
import { Button } from '@langgenius/dify-ui/button'
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 PermissionField from './permission-field'
export type RoleModalMode = 'create' | 'view' | 'edit'
export type RoleModalRole = Role & {
permissions?: string[]
}
export type RoleModalProps = {
mode: RoleModalMode
open: boolean
role?: RoleModalRole
onClose: () => void
onSubmit?: (data: { name: string, description: string, permissions: string[] }) => 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 [permissions, setPermissions] = useState<string[]>(role?.permissions ?? [])
const readonly = mode === 'view'
const { title, description } = TITLES[mode]
const handleSubmit = () => {
onSubmit?.({ name: name.trim(), description: desc.trim(), permissions })
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={e => setName(e.target.value)}
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={e => setDesc(e.target.value)}
placeholder="Describe what this role is responsible for"
disabled={readonly}
className="min-h-24 resize-none"
/>
</div>
<PermissionField
value={permissions}
onChange={setPermissions}
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

View File

@ -0,0 +1,62 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import PermissionPicker from './permission-picker'
import { PERMISSION_MAP } from './permissions-data'
export type PermissionFieldProps = {
value: string[]
onChange: (next: string[]) => void
readonly?: boolean
}
const PermissionField = ({
value,
onChange,
readonly = false,
}: PermissionFieldProps) => {
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((id) => {
const p = PERMISSION_MAP[id]
if (!p)
return null
return (
<span
key={id}
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(id)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
)}
</span>
)
})}
</div>
)}
{!readonly && (
<PermissionPicker value={value} onChange={onChange} />
)}
</div>
)
}
export default PermissionField

View File

@ -0,0 +1,173 @@
'use client'
import type { PermissionGroup } from './permissions-data'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import { PERMISSION_GROUPS } from './permissions-data'
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 filteredGroups = useMemo<PermissionGroup[]>(() => {
const q = search.trim().toLowerCase()
if (!q)
return PERMISSION_GROUPS
return PERMISSION_GROUPS
.map(group => ({
...group,
items: group.items.filter(i => i.name.toLowerCase().includes(q)),
}))
.filter(group => group.items.length > 0)
}, [search])
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.items.reduce(
(acc, i) => acc + (selectedSet.has(i.id) ? 1 : 0),
0,
)
return {
allChecked: checkedCount > 0 && checkedCount === group.items.length,
indeterminate: checkedCount > 0 && checkedCount < group.items.length,
}
}
const toggleGroup = (group: PermissionGroup) => {
const { allChecked, indeterminate } = getGroupState(group)
const ids = group.items.map(i => i.id)
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, groupIndex) => {
const { allChecked, indeterminate } = getGroupState(group)
return (
<DropdownMenuGroup key={group.id}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<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.label}
</span>
</button>
{group.items.map((item) => {
const checked = selectedSet.has(item.id)
return (
<DropdownMenuCheckboxItem
key={item.id}
checked={checked}
onCheckedChange={() => togglePermission(item.id)}
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

View File

@ -0,0 +1,66 @@
export type Permission = {
id: string
name: string
}
export type PermissionGroup = {
id: string
label: string
items: Permission[]
}
export const PERMISSION_GROUPS: PermissionGroup[] = [
{
id: 'general',
label: 'General',
items: [
{ id: 'manage_model_providers', name: 'Manage model providers' },
{ id: 'manage_members', name: 'Manage members' },
{ id: 'manage_roles_permissions', name: 'Manage roles & permissions' },
{ id: 'manage_billing', name: 'Manage billing' },
{ id: 'manage_data_sources', name: 'Manage data sources' },
{ id: 'manage_api_extensions', name: 'Manage API extensions' },
],
},
{
id: 'apps',
label: 'Apps',
items: [
{ id: 'create_apps', name: 'Create apps' },
{ id: 'view_all_apps', name: 'View all apps' },
{ id: 'delete_any_app', name: 'Delete any app' },
],
},
{
id: 'knowledge',
label: 'Knowledge',
items: [
{ id: 'create_knowledge_bases', name: 'Create knowledge bases' },
{ id: 'view_all_knowledge_bases', name: 'View all knowledge bases' },
{ id: 'delete_any_knowledge_base', name: 'Delete any knowledge base' },
],
},
{
id: 'logs_audit',
label: 'Logs & Audit',
items: [
{ id: 'view_all_app_logs', name: 'View all app logs' },
{ id: 'cross_app_log_access', name: 'Cross-app log access' },
{ id: 'view_sensitive_fields', name: 'View sensitive fields' },
],
},
{
id: 'plugins',
label: 'Plugins',
items: [
{ id: 'install_plugins', name: 'Install plugins' },
{ id: 'uninstall_plugins', name: 'Uninstall plugins' },
],
},
]
export const ALL_PERMISSIONS: Permission[] = PERMISSION_GROUPS.flatMap(g => g.items)
export const PERMISSION_MAP: Record<string, Permission> = Object.fromEntries(
ALL_PERMISSIONS.map(p => [p.id, p]),
)

View File

@ -168,6 +168,7 @@ export const ProviderContextProvider = ({
isAllowTransferWorkspace,
isAllowPublishAsCustomKnowledgePipelineTemplate,
humanInputEmailDeliveryEnabled,
enableAccessControl: true, // todo: get from backend
}}
>
{children}

View File

@ -43,6 +43,7 @@ export type ProviderContextState = {
isAllowTransferWorkspace: boolean
isAllowPublishAsCustomKnowledgePipelineTemplate: boolean
humanInputEmailDeliveryEnabled: boolean
enableAccessControl: boolean
}
export const baseProviderContextValue: ProviderContextState = {
@ -76,6 +77,7 @@ export const baseProviderContextValue: ProviderContextState = {
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
enableAccessControl: true,
}
export const ProviderContext = createContext<ProviderContextState>(baseProviderContextValue)

View File

@ -218,6 +218,12 @@
"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",
@ -239,6 +245,25 @@
"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",
@ -609,6 +634,7 @@
"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",
@ -618,6 +644,7 @@
"settings.integrations": "Integrations",
"settings.language": "Language",
"settings.members": "Members",
"settings.permissions": "Permissions",
"settings.plugin": "Plugins",
"settings.provider": "Model Provider",
"settings.workplaceGroup": "WORKSPACE",

View File

@ -218,6 +218,12 @@
"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": "知识库管理员",
@ -239,6 +245,25 @@
"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": "只能使用应用程序,不能建立应用程序",
@ -609,6 +634,7 @@
"provider.saveFailed": "API 密钥保存失败",
"provider.validatedError": "校验失败:",
"provider.validating": "验证密钥中...",
"settings.accessRules": "访问规则",
"settings.account": "我的账户",
"settings.accountGroup": "通用",
"settings.apiBasedExtension": "API 扩展",
@ -618,6 +644,7 @@
"settings.integrations": "集成",
"settings.language": "语言",
"settings.members": "成员",
"settings.permissions": "权限",
"settings.plugin": "插件",
"settings.provider": "模型供应商",
"settings.workplaceGroup": "工作空间",