mirror of
https://github.com/langgenius/dify.git
synced 2026-05-08 20:08:36 +08:00
parent
fca92cc1d1
commit
3a525a609c
@ -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",
|
||||
|
||||
643
api/controllers/console/workspace/rbac.py
Normal file
643
api/controllers/console/workspace/rbac.py
Normal 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),
|
||||
)
|
||||
)
|
||||
@ -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")
|
||||
|
||||
774
api/services/enterprise/rbac_service.py
Normal file
774
api/services/enterprise/rbac_service.py
Normal 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 {})
|
||||
124
api/tests/unit_tests/controllers/console/workspace/test_rbac.py
Normal file
124
api/tests/unit_tests/controllers/console/workspace/test_rbac.py
Normal 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"
|
||||
315
api/tests/unit_tests/services/enterprise/test_rbac_service.py
Normal file
315
api/tests/unit_tests/services/enterprise/test_rbac_service.py
Normal 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",
|
||||
}
|
||||
@ -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
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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') {
|
||||
|
||||
120
web/app/components/access-config-modal/index.tsx
Normal file
120
web/app/components/access-config-modal/index.tsx
Normal 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
|
||||
103
web/app/components/access-rules-editor/index.tsx
Normal file
103
web/app/components/access-rules-editor/index.tsx
Normal 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
|
||||
74
web/app/components/app/access-config/index.tsx
Normal file
74
web/app/components/app/access-config/index.tsx
Normal 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
|
||||
108
web/app/components/apps/app-access-config-modal/index.tsx
Normal file
108
web/app/components/apps/app-access-config-modal/index.tsx
Normal 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
|
||||
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
83
web/app/components/datasets/access-config/index.tsx
Normal file
83
web/app/components/datasets/access-config/index.tsx
Normal 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
|
||||
@ -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', () => {
|
||||
|
||||
@ -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?',
|
||||
}}
|
||||
/>,
|
||||
|
||||
@ -34,6 +34,7 @@ describe('OperationsDropdown', () => {
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
openAccessConfig: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
@ -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',
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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] ?? []
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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]),
|
||||
)
|
||||
@ -168,6 +168,7 @@ export const ProviderContextProvider = ({
|
||||
isAllowTransferWorkspace,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate,
|
||||
humanInputEmailDeliveryEnabled,
|
||||
enableAccessControl: true, // todo: get from backend
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "工作空间",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user