Compare commits

..

76 Commits

Author SHA1 Message Date
c61df1942f Merge branch 'main' into copilot/fix-css-issue 2026-04-29 10:44:49 +08:00
0536549f73 fix: flaky WordExtractor close test in CI (#35652)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
2026-04-29 10:27:02 +08:00
d0956039e7 chore: correction of ru translation (#35645)
Co-authored-by: Смирнов Евгений Владимирович <EvVSmirnov@inno.tech>
2026-04-29 09:59:17 +08:00
38eb04dc98 fix: hit-testing response failed because of Pydantic check. (#35640)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 08:37:13 +00:00
d2e1da269c chore: port one api (#35609) 2026-04-28 08:22:01 +00:00
1d3498f659 fix(web): filter model selector by model name (#35624) 2026-04-28 06:55:10 +00:00
b8dea56198 fix(ci): wait for mysql to accept queries before db migration (#35631) 2026-04-28 06:53:10 +00:00
yyh
e2becd6746 test: cover shared workflow app run (#35634) 2026-04-28 06:29:48 +00:00
yyh
28a26f2d59 refactor: improve scrollbar handling in plugin and model selector UI (#35630) 2026-04-28 06:09:02 +00:00
2566ab9105 fix: prevent signin page content from overflowing on mobile viewports
Replace shrink-0 with min-w-0 on card containers to allow proper
sizing on small screens. Add w-full to inner content containers
for proper mobile width. Fix hard-coded w-[400px] to responsive
w-full md:w-[400px] in webapp-reset-password layout.

Agent-Logs-Url: https://github.com/langgenius/dify/sessions/e361965b-6f86-4c1c-99fc-a55a9c95e5fa

Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-04-28 05:33:11 +00:00
yyh
8c7393ef46 refactor(web): improve a11y and design-system consistency for date/time picker and auto-update strategy picker (#35627)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-28 05:31:38 +00:00
b116faaf10 Initial plan 2026-04-28 05:29:46 +00:00
5a7a955210 fix: prioritize URL conversation_id over localStorage in embedded chatbot (#35519)
Co-authored-by: KimNamWoo <treekim@KimNamWoos-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 05:07:59 +00:00
3e4849d765 fix: align object value remove button of chat variable (#35616) 2026-04-28 05:02:35 +00:00
0c280ef708 fix(test): register baidu_obs mock as pytest plugin (#35618) 2026-04-28 05:02:03 +00:00
282561a861 fix: align auto update time picker to the right (#35621)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-28 04:29:16 +00:00
cbb4cc5d76 fix: show full checklist message tooltip instead of truncated (#35613) 2026-04-28 03:22:47 +00:00
2d6babeeb4 test: add Baidu OBS storage unit tests (#34330) 2026-04-28 01:55:56 +00:00
1065a4840a refactor: move SegmentAttachmentBinding and UploadFile to TypeBase (#30218)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 14:01:50 +00:00
b6aa5a7d69 fix: download and upload package before invoking upgrade in auto-upgrade task (#35599)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:19:56 +00:00
949f930698 fix: keep cleanup tasks resilient to billing API failures (#35600) 2026-04-27 08:51:09 +00:00
65a08ed7ab chore(i18n): sync translations with en-US (#35595)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-27 07:51:03 +00:00
yyh
cc4d6db7c8 chore: update dependency catalog (#35594)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-27 07:29:42 +00:00
6b5d6dacb2 fix: school name can not input (#35597) 2026-04-27 07:16:10 +00:00
89bf75eba9 fix: enhance file uploader with billing support and update translations (#35583)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-27 06:33:34 +00:00
yyh
3a28868a6c ci: upgrade web test runners (#35593) 2026-04-27 06:10:43 +00:00
4036515abe fix: improve variable picker text width allocation (#35587)
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-27 06:07:03 +00:00
6c089cab66 fix(web): migrate variable type selector overlay (#35590)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-27 05:27:19 +00:00
yyh
818a71d637 refactor(web): migrate simple overlay tooltips (#35588)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-27 05:03:38 +00:00
yyh
3db107edc9 chore(ci): increase tsslint heap limit (#35591) 2026-04-27 04:46:43 +00:00
2677d90860 chore(deps): bump the storage group across 1 directory with 3 updates (#35578)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 03:21:37 +00:00
859756c4f6 chore(deps-dev): bump xinference-client from 2.5.0 to 2.7.0 in /api in the vdb group (#35580)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 02:50:20 +00:00
295fb6e74a chore(deps): bump the opentelemetry group in /api with 7 updates (#35576)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 02:46:29 +00:00
2326fb7a83 chore(deps): bump psycopg2-binary from 2.9.11 to 2.9.12 in /api in the database group (#35577)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 02:44:37 +00:00
2d6eaf69f9 chore(deps-dev): bump the dev group in /api with 5 updates (#35581)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-04-27 02:08:59 +00:00
3e826c0000 chore(deps): bump anthropics/claude-code-action from 1.0.101 to 1.0.107 in the github-actions-dependencies group (#35579)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 01:59:22 +00:00
b1b977e284 refactor: quota v3 integration (#35436)
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-27 01:49:40 +00:00
23648141c9 chore(ci): move image builds to depot (#35575) 2026-04-26 16:00:17 -07:00
d6dee43c09 chore(ci): migrate runners to depot 2026-04-26 11:28:46 -07:00
7efc887e32 refactor: port MessageAnnotation (#31005)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 11:47:42 +00:00
8b346e69d9 chore(deps): bump gitpython from 3.1.45 to 3.1.47 in /api (#35570)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 04:21:27 +00:00
ef7ff3356d refactor: port ChildChunk (#30920)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-26 00:59:22 +00:00
99
7b5c0b5045 fix(api): declare flask dependency (#35568) 2026-04-25 20:07:28 +00:00
f00512dd5d test: add P0 workflow run, publish, and share scenarios (#35559) 2026-04-25 04:48:17 +00:00
e6ef774fd5 docs: fix Kubernetes deployment wording (#35547) 2026-04-24 17:59:04 +08:00
ce50c6cf1c chore: port 2 api (#35542)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-04-24 09:07:17 +00:00
7002512106 feat: refactor modals to use Dialog component and add tests for ApiKeyModal and ProviderConfigModal (#35550)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-24 08:43:03 +00:00
c3aebb8403 chore: fix use select style api in orm (#35531)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-04-24 08:35:20 +00:00
0baefa6163 chore(i18n): sync translations with en-US (#35552)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-04-24 08:33:11 +00:00
7bcedcbaab fix: right click node not display the node detail panel (#35554) 2026-04-24 08:31:19 +00:00
yyh
791fc5819d test(dify-ui): disable base ui animations globally (#35467) 2026-04-24 08:12:23 +00:00
2d09c4788d fix: suggest questions more max_tokens (#35533) 2026-04-24 08:02:30 +00:00
9bd5c2f8ec fix: app icon could not only change background (#35537)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-24 07:59:37 +00:00
5e336c47fd feat: marketplace and oauth fixes (#35509)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-04-24 07:53:14 +00:00
be4c828214 feat: add service api of HITL (#32826)
Co-authored-by: Blackoutta <hyytez@gmail.com>
Co-authored-by: QuantumGhost <QuantumGhost@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
2026-04-24 06:37:10 +00:00
yyh
ec450eb7f9 chore(dify-ui): update tooltip and infotip migration (#35543)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-24 06:36:48 +00:00
48e13f65dc fix: sync 35528 (#35539) 2026-04-24 03:59:33 +00:00
38fc2a6574 feat: support key up and down to select variable item (#35527) 2026-04-24 02:32:06 +00:00
ed8d3f3e8d refactor(api): fix pyright errors in jieba, milvus, couchbase, oracle, and router (#34938)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-23 22:30:28 +00:00
0c8dec3315 fix: update node handle opacity and pointer events behavior in components and tests (#35525)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-23 09:24:19 +00:00
38e831c1b3 fix: improve note node (#35461) 2026-04-23 08:54:56 +00:00
1c5d62d98a test(e2e): add app detail navigation and redirect scenarios (#35502) 2026-04-23 07:37:42 +00:00
6b4736bf78 fix: improve collaboration (#35309) 2026-04-23 07:37:05 +00:00
yyh
c9503fd818 fix(web): three small UX fixes on /datasets and /plugins (#35514)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-23 06:46:54 +00:00
yyh
91a1df96cb fix(web): restore "Copied" feedback state on copy buttons (#35513) 2026-04-23 06:40:52 +00:00
5b2c5da945 test(e2e): add publish app happy path scenario (#35503) 2026-04-23 05:05:31 +00:00
b59ecea346 test(auth): add sign-in smoke test and core validation (#35501)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 04:54:45 +00:00
61c0948136 chore: add script to generate openapi v2 json and add in README #35474 (#35477)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-23 03:42:04 +00:00
f746c7bdf2 fix(plugin): handle file input reset and improve local installer close functionality (#35506)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-23 03:03:20 +00:00
2a3deee385 refactor: enhance node handle components with opacity transitions and add tests for visibility behavior (#35494)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 10:50:44 +00:00
4b6803ba06 chore(i18n): sync translations with en-US (#35492)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-22 09:43:21 +00:00
4c908c8f39 refactor: migrate base/select to dify-ui/select (#35487)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 09:35:57 +00:00
afec528f51 feat: improve follow-up settings (#35442)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 08:55:16 +00:00
491061b8f4 fix(web): keep Add model dialog footer visible when form overflows (#35490) 2026-04-22 08:54:23 +00:00
8b1533438f chore: update 3 api (#35481)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-22 08:53:53 +00:00
yyh
ba924fc97b chore: migrate type-check from tsc to tsgo across all workspaces (#35488) 2026-04-22 08:45:54 +00:00
102 changed files with 114 additions and 7257 deletions

View File

@ -23,11 +23,6 @@ class EnterpriseFeatureConfig(BaseSettings):
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
)
RBAC_ENABLED: bool = Field(
description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.",
default=False,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""

View File

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

View File

@ -27,7 +27,6 @@ from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.trigger.constants import TRIGGER_NODE_TYPES
from configs import dify_config
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
@ -38,7 +37,6 @@ from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
DataSource,
@ -332,7 +330,6 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
permission_keys: list[str] = Field(default_factory=list)
@computed_field(return_type=str | None) # type: ignore
@property
@ -478,20 +475,6 @@ class AppListApi(Resource):
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
if app_pagination.items:
if dify_config.RBAC_ENABLED:
app_ids = [str(app.id) for app in app_pagination.items]
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
app_ids,
)
for app in app_pagination.items:
app.permission_keys = permission_keys_map.get(str(app.id), [])
else:
for app in app_pagination.items:
app.permission_keys = []
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]

View File

@ -57,7 +57,6 @@ from models.enums import ApiTokenType, SegmentStatus
from models.provider_ids import ModelProviderID
from services.api_token_service import ApiTokenCache
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.enterprise import rbac_service as enterprise_rbac_service
# Register models for flask_restx to avoid dict type issues in Swagger
dataset_base_model = get_or_create_model("DatasetBase", dataset_fields)
@ -128,14 +127,6 @@ def _validate_doc_form(value: str | None) -> str | None:
return value
def _ensure_permission_keys(dataset: Dataset, *, enabled: bool) -> None:
if not enabled:
setattr(dataset, "permission_keys", [])
return
if not isinstance(getattr(dataset, "permission_keys", None), list):
setattr(dataset, "permission_keys", [])
class DatasetCreatePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=40)
description: str = Field("", max_length=400)
@ -338,19 +329,6 @@ class DatasetListApi(Resource):
query.include_all,
)
for dataset in datasets:
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
if dify_config.RBAC_ENABLED and datasets:
dataset_ids = [str(dataset.id) for dataset in datasets]
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
str(current_tenant_id),
current_user.id,
dataset_ids,
)
for dataset in datasets:
setattr(dataset, "permission_keys", permission_keys_map.get(str(dataset.id), []))
# check embedding setting
provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id)
configurations = provider_manager.get_configurations(tenant_id=current_tenant_id)
@ -432,7 +410,6 @@ class DatasetListApi(Resource):
except services.errors.dataset.DatasetNameDuplicateError:
raise DatasetNameDuplicateError()
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
return marshal(dataset, dataset_detail_fields), 201
@ -457,7 +434,6 @@ class DatasetApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user)
except services.errors.account.NoPermissionError as e:
raise Forbidden(str(e))
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY:
if dataset.embedding_model_provider:
@ -527,7 +503,6 @@ class DatasetApi(Resource):
if dataset is None:
raise NotFound("Dataset not found.")
_ensure_permission_keys(dataset, enabled=dify_config.RBAC_ENABLED)
result_data = cast(dict[str, Any], marshal(dataset, dataset_detail_fields))
tenant_id = current_tenant_id

View File

@ -30,7 +30,6 @@ from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
@ -73,19 +72,6 @@ register_enum_models(console_ns, TenantAccountRole)
register_schema_models(console_ns, AccountWithRole, AccountWithRoleList)
def _serialize_member_roles(current_role: str | None, member_roles: list[enterprise_rbac_service.MemberRoleSummary]) -> list[dict[str, str]]:
if member_roles:
return [{"id": role.id, "name": role.name} for role in member_roles]
if current_role:
return [{"id": current_role, "name": current_role}]
return []
def _normalize_enum_value(value: object) -> str:
normalized = getattr(value, "value", value)
return str(normalized) if normalized is not None else ""
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@ -99,36 +85,7 @@ class MemberListApi(Resource):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_tenant_members(current_user.current_tenant)
if dify_config.RBAC_ENABLED:
member_ids = [member.id for member in members]
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
str(current_user.current_tenant.id),
current_user.id,
member_ids,
)
roles_map = {item.account_id: item.roles for item in member_roles}
else:
roles_map = {}
serialized_members = []
for member in members:
current_role = _normalize_enum_value(member.current_role)
serialized_members.append(
{
"id": member.id,
"name": member.name,
"email": member.email,
"avatar": member.avatar,
"last_login_at": member.last_login_at,
"last_active_at": member.last_active_at,
"created_at": member.created_at,
"role": current_role,
"roles": _serialize_member_roles(current_role, roles_map.get(member.id, [])),
"status": _normalize_enum_value(member.status),
}
)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200

View File

@ -1,606 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator
from werkzeug.exceptions import Forbidden, NotFound
from configs import dify_config
from controllers.console import console_ns
from libs.login import current_account_with_tenant, login_required
from services.enterprise import rbac_service as svc
_LEGACY_WORKSPACE_PERMISSION_KEYS: list[str] = [
# These keys are copied from the enterprise RBAC catalog examples in
# `dify-rbac.md` so the legacy workspace roles stay in the same key format
# as the enterprise RBAC surface.
"workspace.member.manage",
"workspace.role.manage",
]
_LEGACY_APP_PERMISSION_KEYS: list[str] = [
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
"app.acl.access_config",
]
_LEGACY_DATASET_PERMISSION_KEYS: list[str] = [
"dataset.acl.readonly",
"dataset.acl.edit",
"dataset.acl.use",
]
_LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = {
# These legacy role groups predate the RBAC refactor. The mapping keeps the
# old workspace roles readable through the new RBAC endpoint by translating
# each role into the closest enterprise permission keys that already exist
# in the catalog and tests.
"owner": [
*_LEGACY_WORKSPACE_PERMISSION_KEYS,
*_LEGACY_APP_PERMISSION_KEYS,
*_LEGACY_DATASET_PERMISSION_KEYS,
],
"admin": [
*_LEGACY_WORKSPACE_PERMISSION_KEYS,
*_LEGACY_APP_PERMISSION_KEYS,
*_LEGACY_DATASET_PERMISSION_KEYS,
],
"editor": [
*_LEGACY_APP_PERMISSION_KEYS,
*_LEGACY_DATASET_PERMISSION_KEYS,
],
"normal": [
"app.acl.view_layout",
"app.acl.test_and_run",
],
"dataset_operator": [
*_LEGACY_DATASET_PERMISSION_KEYS,
],
}
def _current_ids() -> tuple[str, str]:
"""Return ``(tenant_id, account_id)`` for the authenticated user, or
raise a 404 when no tenant is associated with the session.
"""
user, tenant_id = current_account_with_tenant()
if not tenant_id:
raise NotFound("Current workspace not found")
return tenant_id, user.id
def _payload(model: type[BaseModel]) -> Any:
"""Validate the JSON body against ``model`` or raise ``ValidationError``.
``ValidationError`` bubbles up as HTTP 400 thanks to
``controllers/common/helpers.py`` error handling.
"""
try:
return model.model_validate(console_ns.payload or {})
except ValidationError as exc:
# Re-raise as-is so the upstream error handler renders a 400.
raise exc
def _dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump(mode="json")
class _PaginationQuery(BaseModel):
model_config = ConfigDict(extra="ignore")
page_number: int | None = Field(default=None, ge=1, validation_alias=AliasChoices("page", "page_number"))
results_per_page: int | None = Field(
default=None, ge=1, le=100, validation_alias=AliasChoices("limit", "results_per_page")
)
reverse: bool | None = None
def to_inner_options(self) -> svc.ListOption:
return svc.ListOption.model_validate(self.model_dump())
def _pagination_options() -> svc.ListOption:
return _PaginationQuery.model_validate(request.args.to_dict(flat=True)).to_inner_options()
def _legacy_workspace_roles(options: svc.ListOption | None = None) -> svc.Paginated[svc.RBACRole]:
"""Return the built-in legacy workspace roles in the RBAC list shape.
This keeps the new `/rbac/roles` endpoint compatible with the original
Dify role model when enterprise RBAC is disabled.
"""
legacy_roles = [
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]),
)
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
]
page_number = options.page_number if options and options.page_number is not None else 1
results_per_page = options.results_per_page if options and options.results_per_page is not None else len(legacy_roles)
reverse = options.reverse if options and options.reverse is not None else False
ordered_roles = list(reversed(legacy_roles)) if reverse else legacy_roles
start = max(page_number - 1, 0) * results_per_page
end = start + results_per_page
paged_roles = ordered_roles[start:end]
total_count = len(legacy_roles)
total_pages = (total_count + results_per_page - 1) // results_per_page if results_per_page > 0 else 0
return svc.Paginated[svc.RBACRole](
data=paged_roles,
pagination=svc.Pagination(
total_count=total_count,
per_page=results_per_page,
current_page=page_number,
total_pages=total_pages,
),
)
# ---------------------------------------------------------------------------
# Permission catalogs.
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog")
class RBACWorkspaceCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.workspace(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/app")
class RBACAppCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.app(tenant_id, account_id))
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/dataset")
class RBACDatasetCatalogApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Catalog.dataset(tenant_id, account_id))
# ---------------------------------------------------------------------------
# Roles.
# ---------------------------------------------------------------------------
class _RoleUpsertRequest(BaseModel):
"""Accepts the payload sent by the Create/Edit Role dialog."""
name: str
description: str = ""
permission_keys: list[str] = []
def to_mutation(self) -> svc.RoleMutation:
return svc.RoleMutation(
name=self.name,
description=self.description,
permission_keys=list(self.permission_keys),
)
@console_ns.route("/workspaces/current/rbac/roles")
class RBACRolesApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
options = _pagination_options()
if not dify_config.RBAC_ENABLED:
return _dump(_legacy_workspace_roles(options))
return _dump(svc.RBACService.Roles.list(tenant_id, account_id, options=options))
@login_required
def post(self):
tenant_id, account_id = _current_ids()
request = _payload(_RoleUpsertRequest)
role = svc.RBACService.Roles.create(tenant_id, account_id, request.to_mutation())
return _dump(role), 201
@console_ns.route("/workspaces/current/rbac/roles/<uuid:role_id>")
class RBACRoleItemApi(Resource):
@login_required
def get(self, role_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.Roles.get(tenant_id, account_id, str(role_id)))
@login_required
def put(self, role_id):
tenant_id, account_id = _current_ids()
request = _payload(_RoleUpsertRequest)
role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation())
return _dump(role)
@login_required
def delete(self, role_id):
tenant_id, account_id = _current_ids()
svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id))
return {"result": "success"}
@console_ns.route("/workspaces/current/rbac/roles/<uuid:role_id>/copy")
class RBACRoleCopyApi(Resource):
@login_required
def post(self, role_id):
tenant_id, account_id = _current_ids()
role = svc.RBACService.Roles.copy(tenant_id, account_id, str(role_id))
return _dump(role), 201
# ---------------------------------------------------------------------------
# Access policies (tenant-level permission sets).
# ---------------------------------------------------------------------------
class _AccessPolicyCreateRequest(BaseModel):
name: str
resource_type: svc.RBACResourceType
description: str = ""
permission_keys: list[str] = []
class _AccessPolicyUpdateRequest(BaseModel):
name: str
description: str = ""
permission_keys: list[str] = []
@console_ns.route("/workspaces/current/rbac/access-policies")
class RBACAccessPoliciesApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
# `resource_type` is exposed as a query argument so the UI can show
# only app-scoped or only dataset-scoped permission sets.
resource_type = request.args.get("resource_type") or None
return _dump(
svc.RBACService.AccessPolicies.list(
tenant_id,
account_id,
resource_type=resource_type,
options=_pagination_options(),
)
)
@login_required
def post(self):
tenant_id, account_id = _current_ids()
request = _payload(_AccessPolicyCreateRequest)
policy = svc.RBACService.AccessPolicies.create(
tenant_id,
account_id,
svc.AccessPolicyCreate(
name=request.name,
resource_type=request.resource_type,
description=request.description,
permission_keys=list(request.permission_keys),
),
)
return _dump(policy), 201
@console_ns.route("/workspaces/current/rbac/access-policies/<uuid:policy_id>")
class RBACAccessPolicyItemApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AccessPolicies.get(tenant_id, account_id, str(policy_id)))
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_AccessPolicyUpdateRequest)
policy = svc.RBACService.AccessPolicies.update(
tenant_id,
account_id,
str(policy_id),
svc.AccessPolicyUpdate(
name=request.name,
description=request.description,
permission_keys=list(request.permission_keys),
),
)
return _dump(policy)
@login_required
def delete(self, policy_id):
tenant_id, account_id = _current_ids()
svc.RBACService.AccessPolicies.delete(tenant_id, account_id, str(policy_id))
return {"result": "success"}
@console_ns.route("/workspaces/current/rbac/access-policies/<uuid:policy_id>/copy")
class RBACAccessPolicyCopyApi(Resource):
@login_required
def post(self, policy_id):
tenant_id, account_id = _current_ids()
policy = svc.RBACService.AccessPolicies.copy(tenant_id, account_id, str(policy_id))
return _dump(policy), 201
# ---------------------------------------------------------------------------
# Per-app access (App Access Config).
# ---------------------------------------------------------------------------
class _ReplaceBindingsRequest(BaseModel):
role_ids: list[str] = []
account_ids: list[str] = []
@field_validator("role_ids", "account_ids", mode="before")
@classmethod
def _coerce_bindings(cls, value: Any) -> list[str]:
if value is None:
return []
return value
@console_ns.route("/workspaces/current/rbac/my-permissions")
class RBACMyPermissionsApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.MyPermissions.get(
tenant_id,
account_id,
app_id=request.args.get("app_id") or None,
dataset_id=request.args.get("dataset_id") or None,
)
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policy")
class RBACAppMatrixApi(Resource):
@login_required
def get(self, app_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AppAccess.matrix(tenant_id, account_id, str(app_id)))
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/role-bindings")
class RBACAppRoleBindingsApi(Resource):
@login_required
def get(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.AppAccess.list_role_bindings(tenant_id, account_id, str(app_id), str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/member-bindings")
class RBACAppMemberBindingsApi(Resource):
@login_required
def get(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.AppAccess.list_member_bindings(tenant_id, account_id, str(app_id), str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/access-policies/<uuid:policy_id>/bindings")
class RBACAppBindingsApi(Resource):
@login_required
def put(self, app_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.AppAccess.replace_bindings(
tenant_id,
account_id,
str(app_id),
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
# ---------------------------------------------------------------------------
# Per-dataset access (Knowledge Base Access Config).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policy")
class RBACDatasetMatrixApi(Resource):
@login_required
def get(self, dataset_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.DatasetAccess.matrix(tenant_id, account_id, str(dataset_id)))
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/role-bindings")
class RBACDatasetRoleBindingsApi(Resource):
@login_required
def get(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.DatasetAccess.list_role_bindings(
tenant_id, account_id, str(dataset_id), str(policy_id)
)
)
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/bindings")
class RBACDatasetBindingsApi(Resource):
@login_required
def put(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.DatasetAccess.replace_bindings(
tenant_id,
account_id,
str(dataset_id),
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route(
"/workspaces/current/rbac/datasets/<uuid:dataset_id>/access-policies/<uuid:policy_id>/member-bindings"
)
class RBACDatasetMemberBindingsApi(Resource):
@login_required
def get(self, dataset_id, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.DatasetAccess.list_member_bindings(
tenant_id, account_id, str(dataset_id), str(policy_id)
)
)
# ---------------------------------------------------------------------------
# Workspace-level access (Settings > Access Rules).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy")
class RBACWorkspaceAppMatrixApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
options = _pagination_options()
return _dump(svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id, options=options))
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceAppRoleBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_app_role_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/bindings")
class RBACWorkspaceAppBindingsApi(Resource):
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_app_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceAppMemberBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_app_member_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy")
class RBACWorkspaceDatasetMatrixApi(Resource):
@login_required
def get(self):
tenant_id, account_id = _current_ids()
options = _pagination_options()
return _dump(svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id, options=options))
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/role-bindings")
class RBACWorkspaceDatasetRoleBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_dataset_role_bindings(tenant_id, account_id, str(policy_id))
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/bindings")
class RBACWorkspaceDatasetBindingsApi(Resource):
@login_required
def put(self, policy_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceBindingsRequest)
return _dump(
svc.RBACService.WorkspaceAccess.replace_dataset_bindings(
tenant_id,
account_id,
str(policy_id),
svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)),
)
)
@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies/<uuid:policy_id>/member-bindings")
class RBACWorkspaceDatasetMemberBindingsApi(Resource):
@login_required
def get(self, policy_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.WorkspaceAccess.list_dataset_member_bindings(tenant_id, account_id, str(policy_id))
)
# ---------------------------------------------------------------------------
# Member ↔ role bindings (Settings > Members > Assign roles).
# ---------------------------------------------------------------------------
class _ReplaceMemberRolesRequest(BaseModel):
role_ids: list[str] = []
@field_validator("role_ids", mode="before")
@classmethod
def _coerce_role_ids(cls, value: Any) -> list[str]:
if value is None:
return []
return value
@console_ns.route("/workspaces/current/rbac/members/<uuid:member_id>/rbac-roles")
class RBACMemberRolesApi(Resource):
@login_required
def get(self, member_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.MemberRoles.get(tenant_id, account_id, str(member_id)))
@login_required
def put(self, member_id):
tenant_id, account_id = _current_ids()
request = _payload(_ReplaceMemberRolesRequest)
return _dump(
svc.RBACService.MemberRoles.replace(
tenant_id,
account_id,
str(member_id),
role_ids=list(request.role_ids),
)
)

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,6 @@ from typing import Any
import pytest
from flask.views import MethodView
from configs import dify_config
# kombu references MethodView as a global when importing celery/kombu pools.
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@ -198,7 +196,6 @@ def test_app_partial_serialization_uses_aliases(app_models):
create_user_name="Creator",
author_name="Author",
has_draft_trigger=True,
permission_keys=["app.acl.view_layout"],
)
serialized = AppPartial.model_validate(app_obj, from_attributes=True).model_dump(mode="json")
@ -211,7 +208,6 @@ def test_app_partial_serialization_uses_aliases(app_models):
assert serialized["model_config"]["model"] == {"provider": "openai", "name": "gpt-4o"}
assert serialized["workflow"]["id"] == "wf-1"
assert serialized["tags"][0]["name"] == "Utilities"
assert serialized["permission_keys"] == ["app.acl.view_layout"]
def test_app_detail_with_site_includes_nested_serialization(app_models):
@ -275,7 +271,6 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
icon="first-icon",
created_at=_ts(15),
updated_at=_ts(15),
permission_keys=["app.acl.edit"],
)
item_two = SimpleNamespace(
id="app-11",
@ -303,52 +298,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
assert len(serialized["data"]) == 2
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
assert serialized["data"][1]["icon_url"] is None
assert serialized["data"][0]["permission_keys"] == ["app.acl.edit"]
def test_app_list_api_attaches_permission_keys(app, app_module):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
app_obj = SimpleNamespace(
id="app-1",
name="List App",
desc_or_prompt="Summary",
mode_compatible_with_agent="chat",
mode="chat",
created_at=_ts(15),
updated_at=_ts(15),
permission_keys=[],
)
pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_obj])
with app.test_request_context("/apps"):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
app_module,
"current_account_with_tenant",
lambda: (SimpleNamespace(id="acct-1"), "tenant-1"),
)
monkeypatch.setattr(
app_module.AppService,
"get_paginate_apps",
lambda self, user_id, tenant_id, args_dict: pagination,
)
monkeypatch.setattr(
app_module.FeatureService,
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppPermissions,
"batch_get",
lambda tenant_id, account_id, app_ids: {"app-1": ["app.acl.view_layout", "app.acl.edit"]},
)
resp, status = method(app_module.AppListApi())
assert status == 200
assert app_obj.permission_keys == ["app.acl.view_layout", "app.acl.edit"]
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]

View File

@ -93,48 +93,6 @@ class TestDatasetList:
assert resp["total"] == 1
assert resp["data"][0]["embedding_available"] is True
def test_get_with_rbac_enabled_fetches_permission_keys(self, app):
api = DatasetListApi()
method = unwrap(api.get)
current_user = self._mock_user()
current_user.id = "acct-1"
dataset = MagicMock(id="ds-1")
datasets = [dataset]
marshaled = [self._mock_dataset_dict()]
with app.test_request_context("/datasets"):
with (
patch(
"controllers.console.datasets.datasets.current_account_with_tenant",
return_value=(current_user, "tenant-1"),
),
patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True),
patch.object(
DatasetService,
"get_datasets",
return_value=(datasets, 1),
),
patch(
"controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetPermissions.batch_get",
return_value={"ds-1": ["dataset.acl.readonly", "dataset.acl.edit"]},
) as mock_batch_get,
patch(
"controllers.console.datasets.datasets.marshal",
return_value=marshaled,
),
patch.object(
ProviderManager,
"get_configurations",
return_value=MagicMock(get_models=lambda **_: []),
),
):
resp, status = method(api)
assert status == 200
assert dataset.permission_keys == ["dataset.acl.readonly", "dataset.acl.edit"]
mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["ds-1"])
def test_get_with_ids_filter(self, app):
api = DatasetListApi()
method = unwrap(api.get)

View File

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

View File

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

View File

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

View File

@ -1,568 +0,0 @@
"""Unit tests for services.enterprise.rbac_service.
The enterprise RBAC client is almost pure glue: each method turns a single
``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response
model. Rather than spinning up an HTTP server we monkeypatch that helper and
assert on the arguments it received; that catches both routing regressions
(wrong method / wrong path / wrong params) and model-shape regressions in
one place.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from services.enterprise import rbac_service as svc
MODULE = "services.enterprise.rbac_service"
@pytest.fixture
def mock_send():
with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send:
yield send
def _call_args(send: MagicMock) -> SimpleNamespace:
"""Return the most recent (method, endpoint, kwargs) sent to the mock."""
send.assert_called_once()
args, kwargs = send.call_args
return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs)
class TestCatalog:
def test_workspace_catalog(self, mock_send: MagicMock):
mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]}
out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/role-permissions/catalog"
assert call.tenant_id == "tenant-1"
assert call.account_id == "acct-1"
assert call.json is None
assert call.params is None
assert len(out.groups) == 1
assert out.groups[0].group_key == "workspace"
def test_app_catalog_endpoint(self, mock_send: MagicMock):
mock_send.return_value = {"groups": []}
svc.RBACService.Catalog.app("tenant-1")
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app"
def test_dataset_catalog_endpoint(self, mock_send: MagicMock):
mock_send.return_value = {"groups": []}
svc.RBACService.Catalog.dataset("tenant-1")
assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset"
class TestRoles:
def test_list_forwards_pagination_options(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "role-1",
"tenant_id": "tenant-1",
"type": "workspace",
"category": "global_custom",
"name": "Owner",
"permission_keys": ["workspace.member.manage"],
}
],
"pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1},
}
out = svc.RBACService.Roles.list(
"tenant-1",
"acct-1",
options=svc.ListOption(page_number=2, results_per_page=50, reverse=True),
)
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles"
assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"}
assert out.pagination and out.pagination.total_count == 1
def test_list_omits_params_when_default(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.Roles.list("tenant-1")
assert _call_args(mock_send).params is None
def test_list_coerces_null_permission_keys(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "role-1",
"tenant_id": "tenant-1",
"type": "workspace",
"category": "global_custom",
"name": "Owner",
"permission_keys": None,
}
],
"pagination": None,
}
out = svc.RBACService.Roles.list("tenant-1")
assert out.data[0].permission_keys == []
def test_get_passes_id_query_param(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
def test_create_sends_body(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"])
svc.RBACService.Roles.create("tenant-1", "acct-1", payload)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/roles"
assert call.json == {
"name": "Owner",
"description": "full access",
"permission_keys": ["workspace.member.manage"],
"type": "workspace",
}
def test_update_sends_id_param_and_body(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"}
payload = svc.RoleMutation(name="Owner", permission_keys=["x"])
svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"}
def test_delete_uses_delete_method(self, mock_send: MagicMock):
mock_send.return_value = {"message": "success"}
svc.RBACService.Roles.delete("tenant-1", None, "role-1")
call = _call_args(mock_send)
assert call.method == "DELETE"
assert call.endpoint == "/rbac/roles/item"
assert call.params == {"id": "role-1"}
assert call.account_id is None
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"}
svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1")
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/roles/copy"
assert call.params == {"id": "role-1"}
assert call.account_id == "acct-1"
class TestAccessPolicies:
def test_list_filters_by_resource_type(self, mock_send: MagicMock):
mock_send.return_value = {"data": [], "pagination": None}
svc.RBACService.AccessPolicies.list(
"tenant-1",
"acct-1",
resource_type=svc.RBACResourceType.APP,
options=svc.ListOption(page_number=1),
)
call = _call_args(mock_send)
assert call.endpoint == "/rbac/access-policies"
assert call.params == {"page_number": 1, "resource_type": "app"}
def test_copy_sends_post_with_id_param(self, mock_send: MagicMock):
mock_send.return_value = {
"id": "policy-1-copy",
"resource_type": "app",
"name": "Full access copy",
}
svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1")
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/access-policies/copy"
assert call.params == {"id": "policy-1"}
def test_create_serialises_resource_type_enum(self, mock_send: MagicMock):
mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"}
payload = svc.AccessPolicyCreate(
name="KB only",
resource_type=svc.RBACResourceType.DATASET,
permission_keys=["dataset.acl.readonly"],
)
svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.json == {
"name": "KB only",
"resource_type": "dataset",
"description": "",
"permission_keys": ["dataset.acl.readonly"],
}
class TestResourceAccess:
def test_app_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"app_id": "app-1", "items": []}
out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/apps/access-policy"
assert call.params == {"app_id": "app-1"}
assert out.app_id == "app-1"
def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "binding-1",
"tenant_id": "tenant-1",
"access_policy_id": "policy-1",
"resource_type": "app",
"resource_id": "app-1",
"role_id": "role-1",
"role_name": "Owner",
}
]
}
out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1")
assert out.data[0].role_name == "Owner"
def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"id": "binding-1",
"tenant_id": "tenant-1",
"access_policy_id": "policy-1",
"resource_type": "app",
"resource_id": "app-1",
"account_id": "acct-1",
"account_name": "Alice",
}
]
}
out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1")
assert out.data[0].account_name == "Alice"
def test_app_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"])
svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/apps/access-policy/bindings"
assert call.params == {"app_id": "app-1", "policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]}
def test_dataset_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/datasets/access-policy/bindings"
assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
class TestWorkspaceAccess:
def test_app_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"items": [], "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}}
out = svc.RBACService.WorkspaceAccess.app_matrix(
"tenant-1",
options=svc.ListOption(page_number=2, results_per_page=20),
)
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/workspace/apps/access-policy"
assert call.params == {"page_number": 2, "results_per_page": 20}
assert out.pagination and out.pagination.current_page == 2
def test_dataset_matrix(self, mock_send: MagicMock):
mock_send.return_value = {"items": []}
svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/workspace/datasets/access-policy"
assert call.params is None
def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock):
mock_send.return_value = {
"items": [
{
"policy": {
"id": "policy-1",
"resource_type": "app",
"name": "Workspace App Access",
},
"roles": None,
"accounts": None,
}
],
"pagination": None,
}
out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1")
assert out.items[0].roles == []
assert out.items[0].accounts == []
def test_workspace_app_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.WorkspaceAccess.replace_app_bindings(
"tenant-1", "acct-1", "policy-1", payload
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings"
assert call.params == {"policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock):
mock_send.return_value = {"data": []}
payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"])
svc.RBACService.WorkspaceAccess.replace_dataset_bindings(
"tenant-1", "acct-1", "policy-1", payload
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings"
assert call.params == {"policy_id": "policy-1"}
assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]}
class TestMyPermissions:
def test_get_without_payload_uses_get(self, mock_send: MagicMock):
mock_send.return_value = {
"workspace": {"permission_keys": ["workspace.member.manage"]},
"app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []},
"dataset": {"default_permission_keys": [], "overrides": []},
}
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/my-permissions"
assert call.json is None
assert call.params is None
assert out.workspace.permission_keys == ["workspace.member.manage"]
@pytest.mark.parametrize(
("role", "workspace_keys", "app_keys", "dataset_keys"),
[
(
"owner",
["workspace.member.manage", "workspace.role.manage"],
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
),
(
"admin",
["workspace.member.manage", "workspace.role.manage"],
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
),
(
"editor",
[],
["app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.access_config"],
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
),
(
"normal",
[],
["app.acl.view_layout", "app.acl.test_and_run"],
[],
),
(
"dataset_operator",
[],
[],
["dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.use"],
),
],
)
def test_get_uses_legacy_role_permissions_when_rbac_disabled(
self,
mock_send: MagicMock,
role: str,
workspace_keys: list[str],
app_keys: list[str],
dataset_keys: list[str],
):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = role
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
mock_send.assert_not_called()
assert out.workspace.permission_keys == workspace_keys
assert out.app.default_permission_keys == app_keys
assert out.dataset.default_permission_keys == dataset_keys
assert out.app.overrides == []
assert out.dataset.overrides == []
def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = None
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
mock_send.assert_not_called()
assert out.workspace.permission_keys == []
assert out.app.default_permission_keys == []
assert out.dataset.default_permission_keys == []
def test_get_with_single_resource_filters(self, mock_send: MagicMock):
mock_send.return_value = {
"workspace": {"permission_keys": []},
"app": {"default_permission_keys": [], "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}]},
"dataset": {"default_permission_keys": [], "overrides": []},
}
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/my-permissions"
assert call.params == {"app_id": "app-1"}
assert out.app.overrides[0].resource_id == "app-1"
class TestMemberRoles:
def test_get(self, mock_send: MagicMock):
mock_send.return_value = {
"account_id": "acct-2",
"roles": [
{
"id": "role-1",
"type": "workspace",
"name": "Member",
}
],
}
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/members/rbac-roles"
assert call.params == {"account_id": "acct-2"}
assert out.account_id == "acct-2"
assert out.roles[0].name == "Member"
def test_replace(self, mock_send: MagicMock):
mock_send.return_value = {"account_id": "acct-2", "roles": []}
svc.RBACService.MemberRoles.replace(
"tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"]
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/members/rbac-roles"
assert call.params == {"account_id": "acct-2"}
assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]}
def test_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{
"account_id": "acct-2",
"roles": [
{"id": "role-1", "name": "Admin"},
{"id": "role-2", "name": "Editor"},
],
},
{
"account_id": "acct-3",
"roles": [],
},
]
}
out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/members/rbac-roles/batch"
assert call.json == {"account_ids": ["acct-2", "acct-3"]}
assert out[0].account_id == "acct-2"
assert len(out[0].roles) == 2
class TestResourcePermissions:
def test_app_permissions_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]},
{"resource_id": "app-2", "permission_keys": []},
]
}
out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/apps/permission-keys/batch"
assert call.json == {"app_ids": ["app-1", "app-2"]}
assert out == {
"app-1": ["app.acl.view_layout", "app.acl.edit"],
"app-2": [],
}
def test_dataset_permissions_batch_get(self, mock_send: MagicMock):
mock_send.return_value = {
"data": [
{"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]},
{"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]},
]
}
out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"])
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/datasets/permission-keys/batch"
assert call.json == {"dataset_ids": ["ds-1", "ds-2"]}
assert out == {
"ds-1": ["dataset.acl.readonly"],
"ds-2": ["dataset.acl.edit"],
}
class TestListOption:
def test_empty_produces_empty_params(self):
assert svc.ListOption().to_params() == {}
def test_reverse_serialises_as_lowercase_bool(self):
assert svc.ListOption(reverse=False).to_params()["reverse"] == "false"
assert svc.ListOption(reverse=True).to_params()["reverse"] == "true"
def test_extra_overrides_merge(self):
assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == {
"page_number": 1,
"resource_type": "app",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,8 +12,6 @@ import {
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import * as React from 'react'
@ -102,15 +100,6 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
...(isCurrentWorkspaceEditor
? [{
name: 'Access Config',
href: `/app/${appId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
}]
: []
),
]
return navConfig
}, [t])

View File

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

View File

@ -9,8 +9,6 @@ import {
RiFileTextLine,
RiFocus2Fill,
RiFocus2Line,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -85,13 +83,6 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: RiEqualizer2Fill,
disabled: false,
},
{
name: 'Access Config',
href: `/datasets/${datasetId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
disabled: false,
},
]
if (datasetRes?.provider !== 'external') {

View File

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

View File

@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
)
}
>
<div className="flex w-[400px] flex-col">
<div className="flex w-full flex-col md:w-[400px]">
{children}
</div>
</div>

View File

@ -14,10 +14,10 @@ export default function SignInLayout({ children }: PropsWithChildren) {
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
{/* <Header /> */}
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className="flex justify-center md:w-[440px] lg:w-[600px]">
<div className="flex w-full justify-center md:w-[440px] lg:w-[600px]">
{children}
</div>
</div>

View File

@ -31,10 +31,10 @@ export default function SignInLayout({ children }: any) {
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className="flex flex-col md:w-[400px]">
<div className="flex w-full flex-col md:w-[400px]">
{isLoggedIn
? (
<AppContextProvider>

View File

@ -1,120 +0,0 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useCallback, useState } from 'react'
import AccessRulesEditor from '@/app/components/access-rules-editor'
export type AccessConfigModalProps = {
open: boolean
title: string
description: string
initialRules: AccessRule[]
/**
* Optional override label for the primary action. Defaults to "Save".
*/
saveLabel?: string
/**
* Optional override label for the cancel action. Defaults to "Cancel".
*/
cancelLabel?: string
onClose: () => void
onSave?: (rules: AccessRule[]) => void
}
type AccessConfigModalBodyProps = Omit<AccessConfigModalProps, 'open'>
const AccessConfigModalBody = ({
title,
description,
initialRules,
saveLabel = 'Save',
cancelLabel = 'Cancel',
onClose,
onSave,
}: AccessConfigModalBodyProps) => {
const [rules, setRules] = useState<AccessRule[]>(initialRules)
const handleSave = useCallback(() => {
onSave?.(rules)
onClose()
}, [onClose, onSave, rules])
return (
<DialogContent
className="flex max-h-[85vh] w-[520px] flex-col overflow-hidden p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative shrink-0 px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{title}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{description}
</DialogDescription>
</div>
</div>
<ScrollArea
className="min-h-0 flex-1"
slotClassNames={{ viewport: 'px-6 overscroll-contain' }}
>
<AccessRulesEditor rules={rules} onRulesChange={setRules} />
</ScrollArea>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-divider-subtle px-6 py-4">
<Button variant="secondary" onClick={onClose}>
{cancelLabel}
</Button>
<Button variant="primary" onClick={handleSave}>
{saveLabel}
</Button>
</div>
</DialogContent>
)
}
const AccessConfigModal = ({
open,
title,
description,
initialRules,
saveLabel,
cancelLabel,
onClose,
onSave,
}: AccessConfigModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
{open && (
<AccessConfigModalBody
title={title}
description={description}
initialRules={initialRules}
saveLabel={saveLabel}
cancelLabel={cancelLabel}
onClose={onClose}
onSave={onSave}
/>
)}
</Dialog>
)
}
export default AccessConfigModal

View File

@ -1,103 +0,0 @@
'use client'
import type {
AccessRule,
AssignedRole,
} from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useState } from 'react'
import AccessRuleRow from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import AddRuleTargetsModal from '@/app/components/header/account-setting/access-rules-page/add-rule-targets-modal'
export type AccessRulesEditorProps = {
rules: AccessRule[]
/**
* Called whenever assigned roles/members are mutated. The editor is
* controlled when this callback is provided, uncontrolled (with internal
* state seeded from `rules`) otherwise.
*/
onRulesChange?: (rules: AccessRule[]) => void
className?: string
}
const AccessRulesEditor = ({
rules: rulesProp,
onRulesChange,
className,
}: AccessRulesEditorProps) => {
const isControlled = typeof onRulesChange === 'function'
const [internalRules, setInternalRules] = useState<AccessRule[]>(rulesProp)
const rules = isControlled ? rulesProp : internalRules
const updateRules = useCallback(
(updater: (prev: AccessRule[]) => AccessRule[]) => {
if (isControlled) {
onRulesChange(updater(rulesProp))
return
}
setInternalRules(prev => updater(prev))
},
[isControlled, onRulesChange, rulesProp],
)
const [addingRule, setAddingRule] = useState<AccessRule | null>(null)
const handleAddRole = useCallback((rule: AccessRule) => {
setAddingRule(rule)
}, [])
const handleCloseAddModal = useCallback(() => {
setAddingRule(null)
}, [])
const handleAddSubmit = useCallback(
(_selection: { roleIds: string[], memberIds: string[] }) => {
// TODO: wire up to API when backend is ready.
},
[],
)
const handleRemoveRole = useCallback(
(target: AccessRule, role: AssignedRole) => {
updateRules(prev =>
prev.map(rule =>
rule.id === target.id
? {
...rule,
assignedRoles: rule.assignedRoles.filter(r => r.id !== role.id),
}
: rule,
),
)
},
[updateRules],
)
return (
<div className={cn('flex flex-col', className)}>
{rules.map((rule, index) => (
<AccessRuleRow
key={rule.id}
rule={rule}
showMenu={false}
onAddRole={handleAddRole}
onRemoveRole={handleRemoveRole}
className={cn(index > 0 && 'border-t border-divider-subtle')}
/>
))}
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
initialMemberIds={[]}
onClose={handleCloseAddModal}
onSubmit={handleAddSubmit}
/>
)}
</div>
)
}
export default AccessRulesEditor

View File

@ -1,74 +0,0 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
// TODO: replace with the per-app access rules fetched from the access-rules
// API once available. Mirrors the workspace-level App access rules catalog.
const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [
{
id: 'app-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'app-can-edit',
name: 'Can edit',
description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.',
assignedRoles: [
{ id: 'app-editor', name: 'App Editor' },
{ id: 'it-staff', name: 'IT Staff' },
],
permissions: [],
},
{
id: 'app-can-view-and-use',
name: 'Can view & use',
description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.',
assignedRoles: [
{ id: 'tester', name: 'Tester' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'member', name: 'Member' },
],
permissions: [],
},
{
id: 'app-can-preview',
name: 'Can preview',
description: 'View the app in the list only. Cannot open the editor or use the app.',
assignedRoles: [
{ id: 'partner', name: 'Partner' },
],
permissions: [],
},
]
type AppAccessConfigPageProps = {
appId: string
}
const AppAccessConfigPage = ({ appId: _appId }: AppAccessConfigPageProps) => {
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="w-full px-16 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={DEFAULT_APP_ACCESS_RULES} />
</div>
</div>
</ScrollArea>
)
}
export default AppAccessConfigPage

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control/use-app-access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import Checkbox from '../../base/checkbox'
import Input from '../../base/input'

View File

@ -9,7 +9,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode, SubjectType } from '@/models/access-control'
import { useUpdateAccessMode } from '@/service/access-control/use-app-access-control'
import { useUpdateAccessMode } from '@/service/access-control'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,108 +0,0 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import type { App } from '@/types/app'
import AccessConfigModal from '@/app/components/access-config-modal'
// TODO: replace with the per-app access rules fetched from the access-rules API
// once available. The catalog mirrors the workspace-level App access rules and
// adds app-specific rules that can only be assigned per-app.
const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [
{
id: 'app-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'app-can-edit',
name: 'Can edit',
description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'app-can-view-and-use',
name: 'Can view & use',
description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'app-can-preview',
name: 'Can preview',
description: 'View the app in the list only. Cannot open the editor or use the app.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'app-can-optimize-prompt',
name: 'Can optimize prompt',
description: 'Dedicated prompt optimization access.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
]
export type AppAccessConfigModalProps = {
open: boolean
app: Pick<App, 'id' | 'name'>
onClose: () => void
onSave?: (rules: AccessRule[]) => void
}
const AppAccessConfigModal = ({
open,
app: _app,
onClose,
onSave,
}: AppAccessConfigModalProps) => {
return (
<AccessConfigModal
open={open}
title="App Access Config"
description="Configure access levels for this specific app."
initialRules={DEFAULT_APP_ACCESS_RULES}
onClose={onClose}
onSave={onSave}
/>
)
}
export default AppAccessConfigModal

View File

@ -41,7 +41,7 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { AccessMode } from '@/models/access-control'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -68,9 +68,6 @@ const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/ds
const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), {
ssr: false,
})
const AppAccessConfigModal = dynamic(() => import('@/app/components/apps/app-access-config-modal'), {
ssr: false,
})
type AppCardProps = {
app: App
@ -89,7 +86,6 @@ type AppCardOperationsMenuProps = {
onSwitch: () => void
onDelete: () => void
onAccessControl: () => void
onAccessConfig: () => void
}
const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
@ -103,7 +99,6 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
onSwitch,
onDelete,
onAccessControl,
onAccessConfig,
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
@ -172,10 +167,6 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessConfig)}>
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
@ -226,7 +217,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [showAccessControl, setShowAccessControl] = useState(false)
const [showAccessConfig, setShowAccessConfig] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
@ -298,13 +288,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
})
}, [])
const handleShowAccessConfig = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowAccessConfig(true)
})
}, [])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
@ -567,7 +550,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleShowAccessConfig}
/>
)
: (
@ -582,7 +564,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
onAccessConfig={handleShowAccessConfig}
/>
)}
</DropdownMenuContent>
@ -689,13 +670,6 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
{showAccessControl && (
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
)}
{showAccessConfig && (
<AppAccessConfigModal
open
app={app}
onClose={() => setShowAccessConfig(false)}
/>
)}
</>
)
}

View File

@ -1,83 +0,0 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
// TODO: replace with the per-knowledge-base access rules fetched from the
// access-rules API once available. Mirrors the workspace-level Knowledge Base
// access rules catalog.
const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [
{
id: 'kb-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'kb-can-edit',
name: 'Can edit',
description: 'Edit knowledge base content, modify settings, and run tests.',
assignedRoles: [
{ id: 'kb-editor', name: 'KB Editor' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'it-staff', name: 'IT Staff' },
],
permissions: [],
},
{
id: 'kb-can-view-and-use',
name: 'Can view & use',
description: 'View knowledge base sources, configs, and logs. Cannot modify content.',
assignedRoles: [
{ id: 'member', name: 'Member' },
],
permissions: [],
},
{
id: 'kb-can-preview',
name: 'Can preview',
description: 'View in the list only. Cannot access the detail page.',
assignedRoles: [
{ id: 'partner', name: 'Partner' },
],
permissions: [],
},
{
id: 'kb-can-test',
name: 'Can test',
description: 'Test knowledge base retrieval efficiency in sandbox.',
assignedRoles: [
{ id: 'tester', name: 'Tester' },
],
permissions: [],
},
]
type DatasetAccessConfigPageProps = {
datasetId: string
}
const DatasetAccessConfigPage = ({ datasetId: _datasetId }: DatasetAccessConfigPageProps) => {
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="px-12 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={DEFAULT_KB_ACCESS_RULES} />
</div>
</div>
</ScrollArea>
)
}
export default DatasetAccessConfigPage

View File

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

View File

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

View File

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

View File

@ -10,17 +10,11 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import dynamic from '@/next/dynamic'
import RenameDatasetModal from '../../../rename-modal'
const DatasetAccessConfigModal = dynamic(() => import('../dataset-access-config-modal'), {
ssr: false,
})
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
showAccessConfig: boolean
confirmMessage: string
}
@ -29,7 +23,6 @@ type DatasetCardModalsProps = {
modalState: ModalState
onCloseRename: () => void
onCloseConfirm: () => void
onCloseAccessConfig: () => void
onConfirmDelete: () => void
onSuccess?: () => void
}
@ -39,7 +32,6 @@ const DatasetCardModals = ({
modalState,
onCloseRename,
onCloseConfirm,
onCloseAccessConfig,
onConfirmDelete,
onSuccess,
}: DatasetCardModalsProps) => {
@ -55,13 +47,6 @@ const DatasetCardModals = ({
onSuccess={onSuccess}
/>
)}
{modalState.showAccessConfig && (
<DatasetAccessConfigModal
open
dataset={dataset}
onClose={onCloseAccessConfig}
/>
)}
<AlertDialog open={modalState.showConfirmDelete} onOpenChange={open => !open && onCloseConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">

View File

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

View File

@ -1,108 +0,0 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import type { DataSet } from '@/models/datasets'
import AccessConfigModal from '@/app/components/access-config-modal'
// TODO: replace with the per-knowledge-base access rules fetched from the
// access-rules API once available. The catalog mirrors the workspace-level
// Knowledge Base access rules.
const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [
{
id: 'kb-full-access',
name: 'Full access',
description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'kb-can-edit',
name: 'Can edit',
description: 'Edit knowledge base content, modify settings, and run tests.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'kb-can-view-and-use',
name: 'Can view & use',
description: 'View knowledge base sources, configs, and logs. Cannot modify content.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'kb-can-preview',
name: 'Can preview',
description: 'View in the list only. Cannot access the detail page.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
{
id: 'kb-can-test',
name: 'Can test',
description: 'Test knowledge base retrieval efficiency in sandbox.',
assignedRoles: [
{ id: 'owner', name: 'Owner' },
{ id: 'admin', name: 'Admin' },
{ id: 'marketing-lead', name: 'Marketing Lead' },
{ id: 'kb-admin', name: 'KB Admin' },
{ id: 'app-admin', name: 'App Admin' },
{ id: 'executive', name: 'Executive' },
],
permissions: [],
},
]
export type DatasetAccessConfigModalProps = {
open: boolean
dataset: Pick<DataSet, 'id' | 'name'>
onClose: () => void
onSave?: (rules: AccessRule[]) => void
}
const DatasetAccessConfigModal = ({
open,
dataset: _dataset,
onClose,
onSave,
}: DatasetAccessConfigModalProps) => {
return (
<AccessConfigModal
open={open}
title="Knowledge Base Access Config"
description="Configure access levels for this specific knowledge base."
initialRules={DEFAULT_KB_ACCESS_RULES}
onClose={onClose}
onSave={onSave}
/>
)
}
export default DatasetAccessConfigModal

View File

@ -10,7 +10,6 @@ import { downloadBlob } from '@/utils/download'
type ModalState = {
showRenameModal: boolean
showConfirmDelete: boolean
showAccessConfig: boolean
confirmMessage: string
}
@ -31,7 +30,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
const [modalState, setModalState] = useState<ModalState>({
showRenameModal: false,
showConfirmDelete: false,
showAccessConfig: false,
confirmMessage: '',
})
@ -51,14 +49,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
setModalState(prev => ({ ...prev, showConfirmDelete: false }))
}, [])
const openAccessConfig = useCallback(() => {
setModalState(prev => ({ ...prev, showAccessConfig: true }))
}, [])
const closeAccessConfig = useCallback(() => {
setModalState(prev => ({ ...prev, showAccessConfig: false }))
}, [])
// API mutations
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
@ -132,8 +122,6 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO
openRenameModal,
closeRenameModal,
closeConfirmDelete,
openAccessConfig,
closeAccessConfig,
// Export state
exporting,

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import Loading from '@/app/components/base/loading'
import TextGenerationApp from '@/app/components/share/text-generation'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import AppUnavailable from '../../base/app-unavailable'

View File

@ -1,91 +0,0 @@
'use client'
import type { AccessPolicy } from '@/models/access-control'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import { useCopyAccessRule, useDeleteAccessRule } from '@/service/access-control/use-workspace-access-rules'
export type AccessRuleRowMenuProps = {
rule: AccessPolicy
onEdit?: () => void
}
const AccessRuleRowMenu = ({
rule,
onEdit,
}: AccessRuleRowMenuProps) => {
const [open, setOpen] = useState(false)
const { mutateAsync: copyAccessRule } = useCopyAccessRule(rule.resource_type)
const { mutateAsync: deleteAccessRule } = useDeleteAccessRule(rule.resource_type)
const handleCopyRules = useCallback(() => {
copyAccessRule(rule.id, {
onSuccess: () => {
toast.success('Access rule copied successfully')
setOpen(false)
},
})
}, [copyAccessRule, rule.id])
const handleDelete = useCallback(() => {
deleteAccessRule(rule.id, {
onSuccess: () => {
toast.success('Access rule deleted successfully')
setOpen(false)
},
})
}, [deleteAccessRule, rule.id])
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={open ? 'bg-state-base-hover' : ''}
aria-label="More actions"
/>
)}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[140px]"
>
<DropdownMenuItem
className="system-sm-semibold text-text-secondary"
onClick={onEdit}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="system-sm-semibold text-text-secondary"
onClick={handleCopyRules}
>
Copy
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="system-sm-semibold"
onClick={handleDelete}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AccessRuleRowMenu

View File

@ -1,115 +0,0 @@
'use client'
import type { AccessPolicyWithBindings, BindingType } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useCallback } from 'react'
import {
useUpdateAppAccessRuleBindings,
useUpdateDatasetAccessRuleBindings,
} from '@/service/access-control/use-workspace-access-rules'
import AccessRuleRowMenu from './access-rule-row-menu'
import RoleTag from './role-tag'
export type AccessRuleRowProps = {
rule: AccessPolicyWithBindings
className?: string
showMenu?: boolean
onEdit?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const AccessRuleRow = ({
rule,
className,
showMenu = true,
onEdit,
onAddRole,
}: AccessRuleRowProps) => {
const { policy, role_ids, account_ids } = rule
const { id: policyId, resource_type } = policy
const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule])
const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule])
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
const handleRemoveRole = useCallback((id: string, type: BindingType) => {
const payload = {
id: policyId,
role_ids: role_ids.map(role => role.id),
account_ids: account_ids.map(account => account.id),
}
if (type === 'role') {
payload.role_ids = payload.role_ids.filter(roleId => roleId !== id)
}
else if (type === 'account') {
payload.account_ids = payload.account_ids.filter(accountId => accountId !== id)
}
if (resource_type === 'app') {
updateAppAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
},
})
}
else if (resource_type === 'dataset') {
updateDatasetAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
},
})
}
}, [account_ids, policyId, resource_type, role_ids, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings])
return (
<div className={cn('flex items-start gap-2 py-3.5', className)}>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{policy.name}
</div>
<p className="mt-0.5 system-xs-regular text-text-tertiary">
{policy.description}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{role_ids.map(role => (
<RoleTag
key={role.id}
id={role.id}
label={role.name}
type="role"
onRemove={handleRemoveRole}
/>
))}
{account_ids.map(account => (
<RoleTag
key={account.id}
id={account.id}
label={account.name}
type="account"
onRemove={handleRemoveRole}
/>
))}
<button
type="button"
onClick={handleAddRole}
className="inline-flex h-6 items-center gap-0.5 rounded-md border border-divider-deep px-1.5 system-xs-medium text-text-tertiary hover:border-divider-solid hover:text-text-secondary"
aria-label={`Add role to ${policy.name}`}
>
<span aria-hidden className="i-ri-add-line h-3 w-3" />
Add
</button>
</div>
</div>
{showMenu && (
<AccessRuleRowMenu
onEdit={handleEdit}
rule={policy}
/>
)}
</div>
)
}
export default memo(AccessRuleRow)

View File

@ -1,53 +0,0 @@
'use client'
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
import AccessRuleRow from './access-rule-row'
type AccessRuleSectionProps = {
title: string
rules: AccessPolicyWithBindings[]
createButtonLabel: string
onCreate?: () => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
className?: string
}
const AccessRuleSection = ({
title,
rules,
createButtonLabel,
onCreate,
onEditRule,
onAddRole,
className,
}: AccessRuleSectionProps) => {
return (
<section className={cn('flex flex-col', className)}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="pr-3 system-xs-medium-uppercase tracking-wide text-text-tertiary">
{title}
</h3>
<Button variant="secondary" size="medium" onClick={onCreate}>
{createButtonLabel}
</Button>
</div>
<div className="overflow-hidden">
{rules.map((rule, index) => (
<AccessRuleRow
key={rule.policy.id}
rule={rule}
className={cn(index > 0 && 'border-t border-divider-subtle')}
onEdit={onEditRule}
onAddRole={onAddRole}
/>
))}
</div>
</section>
)
}
export default memo(AccessRuleSection)

View File

@ -1,354 +0,0 @@
'use client'
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useCallback, useMemo, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import { useMembers } from '@/service/use-common'
export type AssignableMemberOption = {
id: string
name: string
email: string
avatarUrl?: string | null
}
type TabKey = 'roles' | 'members'
type AddRuleTargetsModalBaseProps = {
ruleName?: string
initialRoleIds?: string[]
initialMemberIds?: string[]
onClose: () => void
onSubmit: (selection: { roleIds: string[], memberIds: string[] }) => void
}
export type AddRuleTargetsModalProps = AddRuleTargetsModalBaseProps
const TABS: Array<{ key: TabKey, label: string }> = [
{ key: 'roles', label: 'ROLES' },
{ key: 'members', label: 'MEMBERS' },
]
const toMemberOption = (member: Member): AssignableMemberOption => ({
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatar_url ?? member.avatar ?? null,
})
const AddRuleTargetsModalBody = ({
ruleName,
initialRoleIds = [],
initialMemberIds = [],
onClose,
onSubmit,
}: AddRuleTargetsModalBaseProps) => {
const [activeTab, setActiveTab] = useState<TabKey>('roles')
const [keyword, setKeyword] = useState('')
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>(initialRoleIds)
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>(initialMemberIds)
const { data: rolesData, isLoading: rolesLoading } = useWorkspaceRoleList({
page: 1,
limit: 20,
})
const roles = useMemo(() => rolesData?.data ?? [], [rolesData])
const { data: membersData, isLoading: membersLoading } = useMembers()
const members = useMemo<AssignableMemberOption[]>(() => {
const accounts = membersData?.accounts ?? []
return accounts
.filter(account => account.status !== 'banned' && account.status !== 'closed')
.map(toMemberOption)
}, [membersData])
const trimmed = keyword.trim().toLowerCase()
const filteredRoles = useMemo(() => {
if (!trimmed)
return roles
return roles.filter(
role =>
role.name.toLowerCase().includes(trimmed)
|| role.description?.toLowerCase().includes(trimmed),
)
}, [roles, trimmed])
const filteredMembers = useMemo(() => {
if (!trimmed)
return members
return members.filter(
member =>
member.name.toLowerCase().includes(trimmed)
|| member.email.toLowerCase().includes(trimmed),
)
}, [members, trimmed])
const handleSwitchTab = useCallback((tab: TabKey) => {
setActiveTab(tab)
setKeyword('')
}, [])
const toggleRole = useCallback((id: string) => {
setSelectedRoleIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}, [])
const toggleMember = useCallback((id: string) => {
setSelectedMemberIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}, [])
const handleConfirm = useCallback(() => {
onSubmit({ roleIds: selectedRoleIds, memberIds: selectedMemberIds })
onClose()
}, [onClose, onSubmit, selectedMemberIds, selectedRoleIds])
const description = ruleName
? `Select roles or members to grant the "${ruleName}" access level by default.`
: 'Select roles or members to grant this access level by default.'
const summary = (() => {
const parts: string[] = []
parts.push(`${selectedRoleIds.length} ${selectedRoleIds.length === 1 ? 'role' : 'roles'}`)
parts.push(`${selectedMemberIds.length} ${selectedMemberIds.length === 1 ? 'member' : 'members'} selected`)
return parts.join(', ')
})()
return (
<DialogContent
className="flex h-[528px] w-[480px] flex-col overflow-hidden p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative shrink-0 px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
Add Roles or Members
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{description}
</DialogDescription>
</div>
</div>
<div className="shrink-0 border-b border-divider-subtle px-6">
<div role="tablist" aria-label="Targets" className="flex items-center gap-6">
{TABS.map((tab) => {
const active = activeTab === tab.key
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => handleSwitchTab(tab.key)}
className={cn(
'-mb-px border-b-2 py-2.5 system-sm-semibold-uppercase tracking-wide transition-colors outline-none',
active
? 'border-components-tab-active text-text-primary'
: 'border-transparent text-text-tertiary hover:text-text-secondary',
)}
>
{tab.label}
</button>
)
})}
</div>
</div>
<div className="shrink-0 px-6 pt-3 pb-2">
<Input
showLeftIcon
showClearIcon
value={keyword}
onChange={e => setKeyword(e.target.value)}
onClear={() => setKeyword('')}
placeholder={
activeTab === 'roles' ? 'Search roles...' : 'Search members...'
}
/>
</div>
<ScrollArea
className="min-h-0 flex-1"
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
>
{activeTab === 'roles' && (
rolesLoading
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
Loading roles...
</div>
)
: filteredRoles.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No matching roles
</div>
)
: (
<ul className="flex flex-col gap-0.5 pb-2">
{filteredRoles.map((role) => {
const checked = selectedRoleIds.includes(role.id)
const handleToggle = () => toggleRole(role.id)
return (
<li key={role.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description || 'No description'}
</div>
</div>
</div>
</li>
)
})}
</ul>
)
)}
{activeTab === 'members' && (
membersLoading
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
Loading members...
</div>
)
: filteredMembers.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No matching members
</div>
)
: (
<ul className="flex flex-col gap-0.5 pb-2">
{filteredMembers.map((member) => {
const checked = selectedMemberIds.includes(member.id)
const handleToggle = () => toggleMember(member.id)
return (
<li key={member.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none"
/>
<Avatar
avatar={member.avatarUrl ?? null}
name={member.name}
size="md"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{member.name}
</div>
<div className="mt-0.5 truncate system-xs-regular text-text-tertiary">
{member.email}
</div>
</div>
</div>
</li>
)
})}
</ul>
)
)}
</ScrollArea>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{summary}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleConfirm}>
Confirm
</Button>
</div>
</div>
</DialogContent>
)
}
const AddRuleTargetsModal = ({
ruleName,
initialRoleIds,
initialMemberIds,
onClose,
onSubmit,
}: AddRuleTargetsModalProps) => {
return (
<Dialog
open
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<AddRuleTargetsModalBody
ruleName={ruleName}
initialRoleIds={initialRoleIds}
initialMemberIds={initialMemberIds}
onClose={onClose}
onSubmit={onSubmit}
/>
</Dialog>
)
}
export default AddRuleTargetsModal

View File

@ -1,38 +0,0 @@
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { useWorkspaceAppAccessRules } from '@/service/access-control/use-workspace-access-rules'
import AccessRuleSection from './access-rule-section'
type AppAccessRuleSectionProps = {
className?: string
onCreate?: () => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const AppAccessRuleSection = ({
className,
onCreate,
onEditRule,
onAddRole,
}: AppAccessRuleSectionProps) => {
const { data: appAccessRulesResponse } = useWorkspaceAppAccessRules({
page: 1,
limit: 20,
})
const appAccessRules = appAccessRulesResponse?.items || []
return (
<AccessRuleSection
title="App Access Rules"
rules={appAccessRules}
createButtonLabel="Create App permission set"
onCreate={onCreate}
onEditRule={onEditRule}
onAddRole={onAddRole}
className={className}
/>
)
}
export default AppAccessRuleSection

View File

@ -1,38 +0,0 @@
import type { AccessPolicyWithBindings } from '@/models/access-control'
import { useWorkspaceDatasetAccessRules } from '@/service/access-control/use-workspace-access-rules'
import AccessRuleSection from './access-rule-section'
type DatasetAccessRuleSectionProps = {
className?: string
onCreate?: () => void
onEditRule?: (rule: AccessPolicyWithBindings) => void
onAddRole?: (rule: AccessPolicyWithBindings) => void
}
const DatasetAccessRuleSection = ({
className,
onCreate,
onEditRule,
onAddRole,
}: DatasetAccessRuleSectionProps) => {
const { data: datasetAccessRulesResponse } = useWorkspaceDatasetAccessRules({
page: 1,
limit: 20,
})
const datasetAccessRules = datasetAccessRulesResponse?.items || []
return (
<AccessRuleSection
title="Knowledge Base Access Rules"
rules={datasetAccessRules}
createButtonLabel="Create KB permission set"
onCreate={onCreate}
onEditRule={onEditRule}
onAddRole={onAddRole}
className={className}
/>
)
}
export default DatasetAccessRuleSection

View File

@ -1,154 +0,0 @@
'use client'
import type { PermissionSetFormValues, PermissionSetModalMode } from './permission-set-modal'
import type { AccessPolicyResourceType, AccessPolicyWithBindings } from '@/models/access-control'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useCreateAccessRule, useUpdateAppAccessRuleBindings, useUpdateDatasetAccessRuleBindings } from '@/service/access-control/use-workspace-access-rules'
import AddRuleTargetsModal from './add-rule-targets-modal'
import AppAccessRuleSection from './app-access-rule-section'
import DatasetAccessRuleSection from './dataset-access-rule-section'
import PermissionSetModal from './permission-set-modal'
type PermissionSetModalState = {
mode: PermissionSetModalMode
resourceType: AccessPolicyResourceType
initialValues?: PermissionSetFormValues
}
const AccessRulesPage = () => {
const [addingRule, setAddingRule] = useState<AccessPolicyWithBindings | null>(null)
const [permissionSetModalState, setPermissionSetModalState]
= useState<PermissionSetModalState | null>(null)
const closeAddModal = useCallback(() => {
setAddingRule(null)
}, [])
const closePermissionSetModal = useCallback(() => {
setPermissionSetModalState(null)
}, [])
const handleAddRole = useCallback((rule: AccessPolicyWithBindings) => {
setAddingRule(rule)
}, [])
const { mutateAsync: updateAppAccessRuleBindings } = useUpdateAppAccessRuleBindings()
const { mutateAsync: updateDatasetAccessRuleBindings } = useUpdateDatasetAccessRuleBindings()
const handleAddSubmit = useCallback(
(selection: { roleIds: string[], memberIds: string[] }) => {
const { id, resource_type } = addingRule!.policy
const payload = {
id,
role_ids: selection.roleIds,
account_ids: selection.memberIds,
}
if (resource_type === 'app') {
updateAppAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
closeAddModal()
},
})
}
else if (resource_type === 'dataset') {
updateDatasetAccessRuleBindings(payload, {
onSuccess: () => {
toast.success('Access rule updated successfully')
closeAddModal()
},
})
}
},
[addingRule, closeAddModal, updateAppAccessRuleBindings, updateDatasetAccessRuleBindings],
)
const handleCreate = useCallback((resourceType: AccessPolicyResourceType) => {
setPermissionSetModalState({ mode: 'create', resourceType })
}, [])
const handleEdit = useCallback(
(resourceType: AccessPolicyResourceType, rule: AccessPolicyWithBindings) => {
const { policy } = rule
setPermissionSetModalState({
mode: 'edit',
resourceType,
initialValues: {
name: policy.name,
description: policy.description,
permissionKeys: policy.permission_keys,
},
})
},
[],
)
const { mutateAsync: createAccessRule } = useCreateAccessRule(permissionSetModalState?.resourceType)
const handlePermissionSetSubmit = useCallback(
(values: PermissionSetFormValues) => {
const { name, description, permissionKeys } = values
createAccessRule({
name,
description,
permission_keys: permissionKeys,
}, {
onSuccess: () => {
toast.success('Access rule created successfully')
closePermissionSetModal()
},
})
},
[closePermissionSetModal, createAccessRule],
)
const createApp = useCallback(() => handleCreate('app'), [handleCreate])
const createKb = useCallback(() => handleCreate('dataset'), [handleCreate])
const editApp = useCallback(
(rule: AccessPolicyWithBindings) => handleEdit('app', rule),
[handleEdit],
)
const editKb = useCallback(
(rule: AccessPolicyWithBindings) => handleEdit('dataset', rule),
[handleEdit],
)
return (
<>
<div className="flex flex-col gap-6">
<AppAccessRuleSection
onCreate={createApp}
onEditRule={editApp}
onAddRole={handleAddRole}
/>
<DatasetAccessRuleSection
onCreate={createKb}
onEditRule={editKb}
onAddRole={handleAddRole}
/>
</div>
{addingRule && (
<AddRuleTargetsModal
ruleName={addingRule.policy.name}
initialRoleIds={addingRule.role_ids.map(role => role.id)}
initialMemberIds={addingRule.account_ids.map(account => account.id)}
onClose={closeAddModal}
onSubmit={handleAddSubmit}
/>
)}
{permissionSetModalState && (
<PermissionSetModal
open
mode={permissionSetModalState.mode}
resourceType={permissionSetModalState.resourceType}
initialValues={permissionSetModalState.initialValues}
onClose={closePermissionSetModal}
onSubmit={handlePermissionSetSubmit}
/>
)}
</>
)
}
export default AccessRulesPage

View File

@ -1,22 +0,0 @@
import type { AccessPolicyResourceType } from '@/models/access-control'
import { useAppPermissionCatalog, useDatasetPermissionCatalog } from '@/service/access-control/use-permission-catalog'
export const usePermissionsGroups = (resourceType: AccessPolicyResourceType) => {
const { data: appPermissionCatalog } = useAppPermissionCatalog(resourceType === 'app')
const { data: datasetPermissionCatalog } = useDatasetPermissionCatalog(resourceType === 'dataset')
const permissionCatalog = resourceType === 'app' ? appPermissionCatalog : datasetPermissionCatalog
const groups = permissionCatalog?.groups || []
const allPermissions = groups.flatMap(g => g.permissions) || []
const permissionMap = Object.fromEntries(
allPermissions.map(p => [p.key, p]),
)
return {
groups,
permissionMap,
}
}

View File

@ -1,225 +0,0 @@
'use client'
import type { AccessPolicyResourceType } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useState } from 'react'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { usePermissionsGroups } from './hooks'
import PermissionPicker from './permission-picker'
export type PermissionSetModalMode = 'create' | 'edit'
export type PermissionSetFormValues = {
name: string
description: string
permissionKeys: string[]
}
export type PermissionSetModalProps = {
open: boolean
mode: PermissionSetModalMode
resourceType: AccessPolicyResourceType
initialValues?: Partial<PermissionSetFormValues>
onClose: () => void
onSubmit: (values: PermissionSetFormValues) => void
}
const RESOURCE_LABEL: Record<AccessPolicyResourceType, string> = {
app: 'App',
dataset: 'Knowledge Base',
}
const buildTitle = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
const verb = mode === 'create' ? 'Create' : 'Edit'
return `${verb} ${RESOURCE_LABEL[resource]} permission set`
}
const buildDescription = (mode: PermissionSetModalMode, resource: AccessPolicyResourceType): string => {
if (mode === 'edit')
return 'Modify the name, description, and permissions granted for this permission set.'
if (resource === 'app')
return 'Create an app permission set that can be referenced in access rules for quick authorization.'
return 'Create a knowledge base permission set that can be referenced in access rules for quick authorization.'
}
type PermissionSetModalBodyProps = Omit<PermissionSetModalProps, 'open'>
const PermissionSetModalBody = ({
mode,
resourceType,
initialValues,
onClose,
onSubmit,
}: PermissionSetModalBodyProps) => {
const [name, setName] = useState(initialValues?.name ?? '')
const [description, setDescription] = useState(initialValues?.description ?? '')
const [permissionKeys, setPermissionKeys] = useState<string[]>(initialValues?.permissionKeys ?? [])
const { permissionMap } = usePermissionsGroups(resourceType)
const trimmedName = name.trim()
const canSubmit = trimmedName.length > 0
const handleConfirm = () => {
if (!canSubmit)
return
onSubmit({
name: trimmedName,
description: description.trim(),
permissionKeys,
})
onClose()
}
const handleRemovePermission = (key: string) => {
setPermissionKeys(prev => prev.filter(p => p !== key))
}
return (
<DialogContent
className="max-h-[85vh] w-[560px] overflow-visible p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{buildTitle(mode, resourceType)}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{buildDescription(mode, resourceType)}
</DialogDescription>
</div>
</div>
<div className="border-t border-divider-subtle" />
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-1">
<label htmlFor="permission-set-name" className="system-sm-medium text-text-secondary">
permission set name
<span aria-hidden className="ml-0.5 text-text-destructive">*</span>
</label>
<Input
id="permission-set-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Can export DSL"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="permission-set-description" className="system-sm-medium text-text-secondary">
Description
</label>
<Textarea
id="permission-set-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe what this permission set grants"
className="min-h-20 resize-none"
/>
</div>
<div className="flex flex-col gap-2">
<div className="system-sm-medium text-text-secondary">Permissions</div>
{permissionKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{permissionKeys.map((key) => {
const p = permissionMap[key]
if (!p)
return null
return (
<span
key={key}
className={cn(
'inline-flex items-center gap-1 rounded-md bg-util-colors-indigo-indigo-50 px-1.5 py-0.5 system-xs-medium text-text-accent',
'border-[0.5px] border-components-panel-border',
)}
>
<span>{p.name}</span>
<button
type="button"
className="flex h-3.5 w-3.5 items-center justify-center rounded hover:bg-state-base-hover"
aria-label={`Remove ${p.name}`}
onClick={() => handleRemovePermission(key)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
<PermissionPicker
resourceType={resourceType}
value={permissionKeys}
onChange={setPermissionKeys}
/>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<a
href="https://docs.dify.ai/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
<span>Learn more about permissions</span>
<span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" />
</a>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
disabled={!canSubmit}
onClick={handleConfirm}
>
Confirm
</Button>
</div>
</div>
</DialogContent>
)
}
const PermissionSetModal = ({
open,
mode,
resourceType,
initialValues,
onClose,
onSubmit,
}: PermissionSetModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<PermissionSetModalBody
mode={mode}
resourceType={resourceType}
initialValues={initialValues}
onClose={onClose}
onSubmit={onSubmit}
/>
</Dialog>
)
}
export default PermissionSetModal

View File

@ -1,178 +0,0 @@
'use client'
import type { AccessPolicyResourceType, PermissionGroup } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import { usePermissionsGroups } from './hooks'
type PermissionPickerProps = {
resourceType: AccessPolicyResourceType
value: string[]
onChange: (next: string[]) => void
className?: string
}
const PermissionPicker = ({
resourceType,
value,
onChange,
className,
}: PermissionPickerProps) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// Re-focus the search input after the dropdown takes over focus, so the user
// can keep typing to filter permissions.
useEffect(() => {
if (!open)
return
const timer = setTimeout(() => {
inputRef.current?.focus({ preventScroll: true })
}, 0)
return () => clearTimeout(timer)
}, [open])
const { groups } = usePermissionsGroups(resourceType)
const filteredGroups = useMemo<PermissionGroup[]>(() => {
const q = search.trim().toLowerCase()
if (!q)
return groups
return groups
.map(group => ({
...group,
permissions: group.permissions.filter(i => i.name.toLowerCase().includes(q)),
}))
.filter(group => group.permissions.length > 0)
}, [search, groups])
const selectedSet = useMemo(() => new Set(value), [value])
const togglePermission = (id: string) => {
if (selectedSet.has(id))
onChange(value.filter(v => v !== id))
else
onChange([...value, id])
}
const getGroupState = (group: PermissionGroup) => {
const checkedCount = group.permissions.reduce(
(acc, i) => acc + (selectedSet.has(i.key) ? 1 : 0),
0,
)
return {
allChecked: checkedCount > 0 && checkedCount === group.permissions.length,
indeterminate: checkedCount > 0 && checkedCount < group.permissions.length,
}
}
const toggleGroup = (group: PermissionGroup) => {
const { allChecked, indeterminate } = getGroupState(group)
const ids = group.permissions.map(i => i.key)
if (allChecked || indeterminate) {
const idSet = new Set(ids)
onChange(value.filter(v => !idSet.has(v)))
}
else {
const next = new Set(value)
ids.forEach(id => next.add(id))
onChange(Array.from(next))
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger>
<div
className={cn(
'flex cursor-text items-center gap-2 rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-active shadow-xs ring-[0.5px] ring-components-input-border-active',
className,
)}
>
<span aria-hidden className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<input
ref={inputRef}
className="min-w-0 grow appearance-none bg-transparent system-sm-regular text-text-primary caret-primary-600 outline-hidden placeholder:text-text-tertiary"
placeholder="Search permissions..."
value={search}
onChange={e => setSearch(e.target.value)}
onFocus={() => setOpen(true)}
onMouseDown={e => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Escape')
setOpen(false)
}}
/>
<span
aria-hidden
className={cn(
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
open && 'rotate-180',
)}
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="max-h-80 w-[var(--anchor-width)] py-1"
>
{filteredGroups.length === 0 && (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No permissions found
</div>
)}
{filteredGroups.map((group) => {
const { allChecked, indeterminate } = getGroupState(group)
return (
<DropdownMenuGroup key={group.group_key}>
<button
type="button"
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
onClick={() => toggleGroup(group)}
>
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
className="pointer-events-none"
/>
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
{group.group_name}
</span>
</button>
{group.permissions.map((item) => {
const checked = selectedSet.has(item.key)
return (
<DropdownMenuCheckboxItem
key={item.key}
checked={checked}
onCheckedChange={() => togglePermission(item.key)}
className="gap-2 pl-6"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{item.name}
</span>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default PermissionPicker

View File

@ -1,48 +0,0 @@
'use client'
import type { BindingType } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
export type RoleTagProps = {
id: string
label: string
type: BindingType
onRemove?: (id: string, type: BindingType) => void
className?: string
}
const RoleTag = ({
id,
label,
type,
onRemove,
className,
}: RoleTagProps) => {
return (
<span
className={cn(
'inline-flex h-6 max-w-full items-center gap-0.5 rounded-md bg-components-badge-bg-gray-soft px-1.5 system-xs-medium text-text-secondary shadow-xs',
className,
)}
data-testid="access-rule-role-tag"
>
<span className="truncate">{label}</span>
{onRemove && (
<button
type="button"
aria-label={`Remove ${label}`}
onClick={(e) => {
e.stopPropagation()
onRemove(id, type)
}}
className="flex h-4 w-4 items-center justify-center rounded text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
)}
</span>
)
}
export default memo(RoleTag)

View File

@ -3,8 +3,6 @@ export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
PROVIDER: 'provider',
MEMBERS: 'members',
PERMISSIONS: 'permissions',
ACCESS_RULES: 'access-rules',
BILLING: 'billing',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'api-based-extension',

View File

@ -16,14 +16,12 @@ import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import AccessRulesPage from './access-rules-page'
import ApiBasedExtensionPage from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
import PermissionsPage from './permissions-page'
const iconClassName = `
w-5 h-5 mr-2
@ -51,7 +49,7 @@ export default function AccountSetting({
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const activeMenu = activeTab
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo, enableAccessControl } = useProviderContext()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const workplaceGroupItems: GroupItem[] = (() => {
@ -73,23 +71,6 @@ export default function AccountSetting({
},
]
if (enableAccessControl) {
items.push(
{
key: ACCOUNT_SETTING_TAB.PERMISSIONS,
name: t('settings.permissions', { ns: 'common' }),
icon: <span className={cn('i-ri-user-settings-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-user-settings-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.ACCESS_RULES,
name: t('settings.accessRules', { ns: 'common' }),
icon: <span className={cn('i-ri-shield-user-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-shield-user-fill', iconClassName)} />,
},
)
}
if (enableBilling) {
items.push({
key: ACCOUNT_SETTING_TAB.BILLING,
@ -247,8 +228,6 @@ export default function AccountSetting({
<div className="px-4 pt-2 sm:px-8">
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && <ModelProviderPage searchText={searchValue} />}
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
{activeMenu === ACCOUNT_SETTING_TAB.PERMISSIONS && <PermissionsPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ACCESS_RULES && <AccessRulesPage />}
{activeMenu === ACCOUNT_SETTING_TAB.BILLING && <BillingPage />}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}

View File

@ -51,19 +51,11 @@ vi.mock('../invited-modal', () => ({
</div>
),
}))
vi.mock('../role-badges', () => ({
default: ({ roles }: { roles: string[] }) => (
<div data-testid="role-badges">{roles.join(',')}</div>
),
vi.mock('../operation', () => ({
default: () => <div>Member Operation</div>,
}))
vi.mock('../member-menu', () => ({
default: ({ member, onTransferOwnership, canTransferOwnership }: { member: Member, onTransferOwnership?: () => void, canTransferOwnership?: boolean }) => (
<div data-testid="member-menu">
{canTransferOwnership && member.role === 'owner' && onTransferOwnership && (
<button onClick={onTransferOwnership}>Transfer ownership</button>
)}
</div>
),
vi.mock('../operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
}))
vi.mock('../transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
@ -73,16 +65,6 @@ vi.mock('../transfer-ownership-modal', () => ({
</div>
),
}))
vi.mock('../member-details-modal', () => ({
default: ({ member, onClose, canAssignRoles }: { member: Member, onClose: () => void, canAssignRoles?: boolean }) => (
<div>
<div>Member Details Modal</div>
<div data-testid="details-member-name">{member.name}</div>
<div data-testid="details-can-assign">{String(canAssignRoles)}</div>
<button onClick={onClose}>Close Member Details Modal</button>
</div>
),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: () => <div>Upgrade Button</div>,
}))
@ -378,52 +360,6 @@ describe('MembersPage', () => {
expect(screen.getByText('common.members.normal'))!.toBeInTheDocument()
})
it('should open member details modal when a member row is clicked', async () => {
const user = userEvent.setup()
renderMembersPage()
await user.click(screen.getByTestId('member-row-2'))
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
expect(screen.getByTestId('details-member-name'))!.toHaveTextContent('Admin User')
await user.click(screen.getByRole('button', { name: 'Close Member Details Modal' }))
expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument()
})
it('should open member details modal via keyboard Enter', async () => {
const user = userEvent.setup()
renderMembersPage()
const row = screen.getByTestId('member-row-2')
row.focus()
await user.keyboard('{Enter}')
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
})
it('should not allow assigning roles from member details when target is owner', async () => {
const user = userEvent.setup()
renderMembersPage()
await user.click(screen.getByTestId('member-row-1'))
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
})
it('should not open member details when clicking the member menu area', async () => {
const user = userEvent.setup()
renderMembersPage()
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,

View File

@ -1,218 +0,0 @@
'use client'
import type { Member } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
export type AssignableRole = {
id: string
name: string
description?: string
}
export type AssignRolesModalProps = {
open: boolean
member: Member
onClose: () => void
onSubmit: (roleIds: string[]) => void
}
type AssignRolesModalBodyProps = {
roles: AssignableRole[]
} & Omit<AssignRolesModalProps, 'open'>
// TODO: replace with roles fetched from the permissions API once available.
const MOCK_ASSIGNABLE_ROLES: AssignableRole[] = [
{ id: 'admin', name: 'Admin', description: 'Full access to workspace management and settings' },
{ id: 'editor', name: 'Editor', description: 'Create and edit resources without settings access' },
{ id: 'member', name: 'Member', description: 'Basic workspace access' },
{ id: 'auditor', name: 'Auditor', description: 'View application logs and audit trails' },
{ id: 'tester', name: 'Tester', description: 'Test applications in sandbox environments' },
]
const AssignRolesModalBody = ({
roles,
member,
onClose,
onSubmit,
}: AssignRolesModalBodyProps) => {
const { t } = useTranslation()
const [selected, setSelected] = useState<string[]>(() => {
const match = MOCK_ASSIGNABLE_ROLES.find(r => r.id === member.role)
return match ? [match.id] : []
})
const [keyword, setKeyword] = useState('')
const filteredRoles = useMemo(() => {
const trimmed = keyword.trim().toLowerCase()
if (!trimmed)
return roles
return roles.filter(
role =>
role.name.toLowerCase().includes(trimmed)
|| role.description?.toLowerCase().includes(trimmed),
)
}, [roles, keyword])
const toggle = (id: string) => {
setSelected(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}
const handleConfirm = () => {
onSubmit(selected)
onClose()
}
return (
<DialogContent
className="flex h-[484px] w-[480px] flex-col overflow-hidden p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative shrink-0 px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{t('members.assignRolesModal.title', { ns: 'common', defaultValue: 'Assign Roles' })}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.description', {
ns: 'common',
defaultValue:
'Select roles to assign to this member. All permissions from selected roles will be combined.',
})}
</DialogDescription>
</div>
</div>
<div className="shrink-0 px-6">
<Input
showLeftIcon
showClearIcon
value={keyword}
onChange={e => setKeyword(e.target.value)}
onClear={() => setKeyword('')}
placeholder={t('members.assignRolesModal.searchPlaceholder', {
ns: 'common',
defaultValue: 'Search roles...',
})}
/>
</div>
<ScrollArea
className="mt-2 min-h-0 flex-1"
slotClassNames={{ viewport: 'px-3 overscroll-contain' }}
>
{filteredRoles.length === 0
? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t('members.assignRolesModal.empty', {
ns: 'common',
defaultValue: 'No matching roles',
})}
</div>
)
: (
<ul className="flex flex-col gap-0.5">
{filteredRoles.map((role) => {
const checked = selected.includes(role.id)
const handleToggle = () => toggle(role.id)
return (
<li key={role.id}>
<div
role="checkbox"
aria-checked={checked}
tabIndex={0}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
handleToggle()
}
}}
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{role.name}
</div>
{role.description && (
<div className="mt-0.5 system-xs-regular text-text-tertiary">
{role.description}
</div>
)}
</div>
</div>
</li>
)
})}
</ul>
)}
</ScrollArea>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('members.assignRolesModal.selectedCount', {
ns: 'common',
count: selected.length,
defaultValue: '{{count}} selected',
})}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
</DialogContent>
)
}
const AssignRolesModal = ({
open,
member,
onClose,
onSubmit,
}: AssignRolesModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<AssignRolesModalBody
roles={MOCK_ASSIGNABLE_ROLES}
member={member}
onClose={onClose}
onSubmit={onSubmit}
/>
</Dialog>
)
}
export default AssignRolesModal

View File

@ -1,9 +1,9 @@
'use client'
import type { InvitationResult, Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import type { InvitationResult } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
@ -11,6 +11,7 @@ import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { LanguagesSupported } from '@/i18n-config/language'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common'
@ -18,8 +19,8 @@ import EditWorkspaceModal from './edit-workspace-modal'
import InviteButton from './invite-button'
import InviteModal from './invite-modal'
import InvitedModal from './invited-modal'
import MemberDetailsModal from './member-details-modal'
import MemberRow from './member-row'
import Operation from './operation'
import TransferOwnership from './operation/transfer-ownership'
import TransferOwnershipModal from './transfer-ownership-modal'
const MembersPage = () => {
@ -36,6 +37,7 @@ const MembersPage = () => {
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
const { data, refetch } = useMembers()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow()
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
@ -45,30 +47,13 @@ const MembersPage = () => {
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
const [detailsMember, setDetailsMember] = useState<Member | null>(null)
const handleAssignRolesSubmit = (_roleIds: string[]) => {
// TODO: wire to backend once multi-role member endpoint is ready.
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
refetch()
}
const handleOpenDetails = useCallback((member: Member) => {
setDetailsMember(member)
}, [])
const handleTransferOwnership = useCallback(() => {
setShowTransferOwnershipModal(true)
}, [])
return (
<>
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-3 rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-subtle bg-linear-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[20px]">
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
</span>
<span className="bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="grow">
<div className="flex items-center gap-1 system-md-semibold text-text-secondary">
@ -133,24 +118,42 @@ const MembersPage = () => {
<div className="overflow-visible lg:overflow-visible">
<div className="flex min-w-[480px] items-center border-b border-divider-regular py-[7px]">
<div className="grow px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.name', { ns: 'common' })}</div>
<div className="w-[120px] shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
<div className="w-[215px] shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
<div className="w-[104px] shrink-0 system-xs-medium-uppercase text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
<div className="w-[96px] shrink-0 px-3 system-xs-medium-uppercase text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
</div>
<div className="relative min-w-[480px]">
{accounts.map(account => (
<MemberRow
key={account.id}
member={account}
roleLabel={RoleMap[account.role] || RoleMap.normal}
isCurrentUser={userProfile.email === account.email}
canManage={isCurrentWorkspaceManager}
operatorRole={currentWorkspace.role}
canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace}
onOpenDetails={handleOpenDetails}
onOperate={refetch}
onTransferOwnership={handleTransferOwnership}
/>
))}
{
accounts.map(account => (
<div key={account.id} className="flex border-b border-divider-subtle">
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={account.avatar_url} size="sm" className="mr-2" name={account.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
{account.name}
{account.status === 'pending' && <span className="ml-1 system-xs-medium text-text-warning">{t('members.pending', { ns: 'common' })}</span>}
{userProfile.email === account.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}
</div>
<div className="system-xs-regular text-text-tertiary">{account.email}</div>
</div>
</div>
<div className="flex w-[104px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
<div className="flex w-[96px] shrink-0 items-center">
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
)}
{!isCurrentWorkspaceOwner && (
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div>
</div>
))
}
</div>
</div>
</div>
@ -188,19 +191,6 @@ const MembersPage = () => {
onClose={() => setShowTransferOwnershipModal(false)}
/>
)}
{detailsMember && (
<MemberDetailsModal
open={!!detailsMember}
member={detailsMember}
roleLabel={RoleMap[detailsMember.role] || RoleMap.normal}
canAssignRoles={
isCurrentWorkspaceManager
&& detailsMember.role !== 'owner'
}
onClose={() => setDetailsMember(null)}
onAssignSubmit={handleAssignRolesSubmit}
/>
)}
</>
)
}

View File

@ -1,145 +0,0 @@
'use client'
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AssignRolesModal from '../assign-roles-modal'
import PermissionRoleChip from './permission-role-chip'
export type MemberDetailsModalProps = {
open: boolean
member: Member
roleLabel: string
canAssignRoles?: boolean
onClose: () => void
onAssignSubmit?: (roleIds: string[]) => void
}
const MemberDetailsModal = ({
open,
member,
roleLabel,
canAssignRoles = false,
onClose,
onAssignSubmit,
}: MemberDetailsModalProps) => {
const { t } = useTranslation()
const [assignOpen, setAssignOpen] = useState(false)
const assignedRoles = [{ key: member.role, label: roleLabel }]
const handleAssignSubmit = (ids: string[]) => {
onAssignSubmit?.(ids)
setAssignOpen(false)
}
return (
<>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next)
onClose()
}}
>
<DialogContent className="w-[440px] overflow-visible p-0" backdropProps={{ forceRender: true }}>
<div className="relative px-6 pt-6 pb-5">
<DialogCloseButton />
<DialogTitle className="pr-8 system-xl-semibold text-text-primary">
{t('members.memberDetails.title', {
ns: 'common',
defaultValue: 'Member Details',
})}
</DialogTitle>
<div className="mt-5 flex items-center gap-3">
<Avatar
avatar={member.avatar_url}
name={member.name}
size="2xl"
/>
<div className="min-w-0 flex-1">
<div className="truncate system-md-semibold text-text-primary">
{member.name}
</div>
<div className="truncate system-xs-regular text-text-tertiary">
{member.email}
</div>
</div>
</div>
</div>
<div className="border-t border-divider-subtle px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 system-sm-semibold text-text-secondary">
<span>
{t('members.memberDetails.assignedRoles', {
ns: 'common',
defaultValue: 'Assigned Roles',
})}
</span>
<span className="system-xs-medium text-text-tertiary">
{assignedRoles.length}
</span>
</div>
{canAssignRoles && (
<Button
variant="ghost"
size="small"
onClick={() => setAssignOpen(true)}
>
<span
aria-hidden
className="mr-0.5 i-ri-add-line h-3.5 w-3.5"
/>
{t('members.memberDetails.assign', {
ns: 'common',
defaultValue: 'Assign',
})}
</Button>
)}
</div>
<div className="mt-4">
<div className="mb-2 system-2xs-medium-uppercase text-text-tertiary">
{t('members.memberDetails.generalGroup', {
ns: 'common',
defaultValue: 'GENERAL',
})}
</div>
<div className="flex flex-wrap gap-1.5">
{assignedRoles.map(role => (
<PermissionRoleChip
key={role.key}
roleKey={role.key}
label={role.label}
highlighted
/>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{assignOpen && (
<AssignRolesModal
open={assignOpen}
member={member}
onClose={() => setAssignOpen(false)}
onSubmit={handleAssignSubmit}
/>
)}
</>
)
}
export default memo(MemberDetailsModal)

View File

@ -1,102 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { getRolePermissionKeys } from './role-permissions'
export type PermissionRoleChipProps = {
roleKey: string
label: string
highlighted?: boolean
onRemove?: () => void
className?: string
}
const PermissionRoleChip = ({
roleKey,
label,
highlighted = false,
onRemove,
className,
}: PermissionRoleChipProps) => {
const { t } = useTranslation()
const permissions = getRolePermissionKeys(roleKey)
const hasPermissions = permissions.length > 0
const chip = (
<span
className={cn(
'inline-flex h-6 max-w-full cursor-default items-center gap-1 rounded-md px-1.5 system-xs-medium shadow-xs',
highlighted
? 'bg-state-accent-hover text-text-accent'
: 'bg-background-body text-text-secondary',
className,
)}
data-testid="permission-role-chip"
data-role-key={roleKey}
>
<span className="truncate">{label}</span>
{onRemove && (
<button
type="button"
aria-label={t('members.memberDetails.removeRoleAria', {
ns: 'common',
role: label,
defaultValue: 'Remove {{role}} role',
})}
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className={cn(
'flex h-4 w-4 items-center justify-center rounded hover:bg-black/5',
highlighted ? 'text-text-accent' : 'text-text-tertiary',
)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
)}
</span>
)
if (!hasPermissions)
return chip
return (
<PreviewCard>
<PreviewCardTrigger render={chip} />
<PreviewCardContent
placement="bottom-start"
popupClassName="min-w-[200px] max-w-[280px] p-3"
>
<div className="mb-2 system-sm-semibold text-text-accent">
{label}
</div>
<ul className="flex flex-col gap-1.5 system-xs-regular text-text-secondary">
{permissions.map(key => (
<li key={key} className="flex items-start gap-2">
<span
aria-hidden
className="mt-[7px] inline-block h-1 w-1 shrink-0 rounded-full bg-text-tertiary"
/>
<span>
{t(`members.memberDetails.permissions.${key}`, {
ns: 'common',
defaultValue: key,
})}
</span>
</li>
))}
</ul>
</PreviewCardContent>
</PreviewCard>
)
}
export default memo(PermissionRoleChip)

View File

@ -1,36 +0,0 @@
// TODO: replace with permissions fetched from the permissions API once available.
// Mock mapping from a workspace role key to the list of i18n keys describing
// what permission points that role grants.
export const ROLE_PERMISSION_KEYS: Record<string, string[]> = {
owner: [
'inviteMembers',
'removeMembers',
'assignRoles',
'workspaceSettings',
'manageBilling',
'transferOwnership',
],
admin: [
'inviteMembers',
'removeMembers',
'assignRoles',
'workspaceSettings',
'manageBilling',
],
editor: [
'createApps',
'editApps',
'createDatasets',
'editDatasets',
],
dataset_operator: [
'manageDatasets',
],
normal: [
'useApps',
],
}
export const getRolePermissionKeys = (roleKey: string): string[] => {
return ROLE_PERMISSION_KEYS[roleKey] ?? []
}

View File

@ -1,134 +0,0 @@
'use client'
import type { Member } from '@/models/common'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { deleteMemberOrCancelInvitation } from '@/service/common'
import AssignRolesModal from './assign-roles-modal'
type MemberMenuProps = {
member: Member
operatorRole: string
canTransferOwnership?: boolean
onOperate: () => void
onTransferOwnership?: () => void
}
const MemberMenu = ({
member,
operatorRole,
canTransferOwnership = false,
onOperate,
onTransferOwnership,
}: MemberMenuProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const isOwner = member.role === 'owner'
const canAssignRoles
= !isOwner && (operatorRole === 'owner' || operatorRole === 'admin')
const canRemove = !isOwner
const showTransferOwnership = isOwner && canTransferOwnership
if (!canAssignRoles && !canRemove && !showTransferOwnership)
return null
const handleOpenAssignRoles = () => {
setOpen(false)
setAssignModalOpen(true)
}
const handleAssignRolesSubmit = (_roleIds: string[]) => {
// TODO: wire to backend once multi-role member endpoint is ready.
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
onOperate()
}
const handleRemove = async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
onOperate()
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
catch {
}
}
const handleTransferOwnership = () => {
setOpen(false)
onTransferOwnership?.()
}
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover')}
aria-label={t('members.memberActions', { ns: 'common', defaultValue: 'Member actions' })}
/>
)}
>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[180px] rounded-xl p-1"
>
{canAssignRoles && (
<DropdownMenuItem
className="system-sm-medium text-text-secondary"
onClick={handleOpenAssignRoles}
>
{t('members.assignRoles', { ns: 'common', defaultValue: 'Assign Roles' })}
</DropdownMenuItem>
)}
{showTransferOwnership && (
<DropdownMenuItem
className="system-sm-medium text-text-secondary"
onClick={handleTransferOwnership}
>
{t('members.transferOwnership', { ns: 'common' })}
</DropdownMenuItem>
)}
{(canAssignRoles || showTransferOwnership) && canRemove && (
<DropdownMenuSeparator />
)}
{canRemove && (
<DropdownMenuItem
variant="destructive"
className="system-sm-medium"
onClick={handleRemove}
>
{t('members.removeFromTeam', { ns: 'common' })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{assignModalOpen && (
<AssignRolesModal
open={assignModalOpen}
member={member}
onClose={() => setAssignModalOpen(false)}
onSubmit={handleAssignRolesSubmit}
/>
)}
</>
)
}
export default memo(MemberMenu)

View File

@ -1,117 +0,0 @@
'use client'
import type { KeyboardEvent } from 'react'
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import MemberMenu from './member-menu'
import RoleBadges from './role-badges'
type MemberRowProps = {
member: Member
roleLabel: string
isCurrentUser: boolean
canManage: boolean
operatorRole: string
canTransferOwnership: boolean
onOpenDetails: (member: Member) => void
onOperate: () => void
onTransferOwnership: () => void
}
const MemberRow = ({
member,
roleLabel,
isCurrentUser,
canManage,
operatorRole,
canTransferOwnership,
onOpenDetails,
onOperate,
onTransferOwnership,
}: MemberRowProps) => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const openDetails = useCallback(() => {
onOpenDetails(member)
}, [member, onOpenDetails])
const handleRowKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openDetails()
}
}, [openDetails])
const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
const stopPropagationOnKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ')
e.stopPropagation()
}, [])
return (
<div
role="button"
tabIndex={0}
data-testid={`member-row-${member.id}`}
aria-label={t('members.memberDetails.openAria', {
ns: 'common',
name: member.name,
defaultValue: 'Open member details for {{name}}',
})}
className="flex cursor-pointer border-b border-divider-subtle hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
onClick={openDetails}
onKeyDown={handleRowKeyDown}
>
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
{member.name}
{member.status === 'pending' && (
<span className="ml-1 system-xs-medium text-text-warning">
{t('members.pending', { ns: 'common' })}
</span>
)}
{isCurrentUser && (
<span className="system-xs-regular text-text-tertiary">
{t('members.you', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">{member.email}</div>
</div>
</div>
<div className="flex w-[120px] shrink-0 items-center py-2 system-sm-regular text-text-secondary">
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
</div>
<div
className="flex w-[215px] shrink-0 items-center gap-2 px-3"
onClick={stopPropagationOnClick}
onKeyDown={stopPropagationOnKeyDown}
role="presentation"
>
<RoleBadges
className="grow"
roles={[roleLabel]}
/>
{canManage && (
<MemberMenu
member={member}
operatorRole={operatorRole}
canTransferOwnership={canTransferOwnership}
onOperate={onOperate}
onTransferOwnership={onTransferOwnership}
/>
)}
</div>
</div>
)
}
export default memo(MemberRow)

View File

@ -1,53 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
type RoleBadgeProps = {
label: string
className?: string
}
const RoleBadge = ({ label, className }: RoleBadgeProps) => {
return (
<span
className={cn(
'inline-flex h-5 max-w-full items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-secondary shadow-xs',
className,
)}
>
<span className="truncate">{label}</span>
</span>
)
}
export type RoleBadgesProps = {
roles: string[]
max?: number
className?: string
}
const RoleBadges = ({ roles, max = 2, className }: RoleBadgesProps) => {
if (!roles.length)
return null
const visible = roles.slice(0, max)
const overflow = roles.slice(max)
return (
<div className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}>
{visible.map(role => (
<RoleBadge key={role} label={role} />
))}
{overflow.length > 0 && (
<span
className="inline-flex h-5 cursor-default items-center rounded-md bg-background-body px-1.5 system-xs-medium text-text-tertiary shadow-xs"
>
{`+${overflow.length}`}
</span>
)}
</div>
)
}
export default memo(RoleBadges)

View File

@ -1,27 +0,0 @@
import type { RoleListGroup } from './role-list'
import type { RoleListResponse } from '@/models/access-control'
export const formatRoleGroups = (roleListResponse: RoleListResponse | undefined): RoleListGroup[] => {
if (!roleListResponse)
return []
const result: RoleListGroup[] = []
const builtinRoles = roleListResponse.data.filter(role => role.is_builtin)
const customRoles = roleListResponse.data.filter(role => !role.is_builtin)
if (builtinRoles.length > 0) {
result.push({
id: 'builtin',
category: 'global_system_default',
title: 'System Roles',
items: builtinRoles,
})
}
if (customRoles.length > 0) {
result.push({
id: 'custom',
category: 'global_custom',
title: 'Custom Roles',
items: customRoles,
})
}
return result
}

View File

@ -1,14 +0,0 @@
import type { PaginationParameters } from '@/models/access-control'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import { formatRoleGroups } from './helpers'
export const useRoleGroups = (params?: PaginationParameters) => {
const { data: roleList, isLoading } = useWorkspaceRoleList(params)
const roleGroups = formatRoleGroups(roleList)
return {
roleGroups,
isLoading,
}
}

View File

@ -1,106 +0,0 @@
'use client'
import type { RoleModalMode, submitRoleData } from './role-modal'
import type { Role } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles'
import { useRoleGroups } from './hooks'
import RoleList from './role-list'
import RoleModal from './role-modal'
type ModalState = {
mode: RoleModalMode
role?: Role
} | null
const PermissionsPage = () => {
const [modalState, setModalState] = useState<ModalState>(null)
const { roleGroups } = useRoleGroups()
const { mutateAsync: createWorkspaceRole } = useCreateWorkspaceRole()
const { mutateAsync: updateWorkspaceRole } = useUpdateWorkspaceRole()
const openCreate = useCallback(() => {
setModalState({ mode: 'create' })
}, [])
const handleView = useCallback((role: Role) => {
setModalState({ mode: 'view', role })
}, [])
const handleEdit = useCallback((role: Role) => {
setModalState({ mode: 'edit', role })
}, [])
const closeModal = useCallback(() => setModalState(null), [])
const handleSubmit = useCallback(
(data: submitRoleData) => {
const { name, description, permissionKeys } = data
const mode = modalState?.mode ?? ''
const roleId = modalState?.role?.id ?? ''
if (mode === 'create') {
createWorkspaceRole({ name, description, permission_keys: permissionKeys }, {
onSuccess: () => {
toast.success('Role created successfully')
closeModal()
},
})
}
else if (mode === 'edit') {
updateWorkspaceRole({ id: roleId, name, description, permission_keys: permissionKeys }, {
onSuccess: () => {
toast.success('Role updated successfully')
closeModal()
},
})
}
},
[createWorkspaceRole, updateWorkspaceRole, closeModal, modalState],
)
return (
<>
<div className="flex flex-col">
<div className="mb-4 flex items-center gap-3 rounded-xl border-t-[0.5px] border-l-[0.5px] border-divider-subtle bg-linear-to-bl from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-3 pr-5">
<div className="flex grow flex-col gap-y-1">
<div className="system-md-semibold text-text-primary">
Default Global
</div>
<div className="system-sm-regular text-text-tertiary">
A default global permission scheme applied to the workspace
</div>
</div>
<div className="flex items-center">
<Button
variant="primary"
size="small"
onClick={openCreate}
>
+ Add Role
</Button>
</div>
</div>
<RoleList
groups={roleGroups}
onView={handleView}
onEdit={handleEdit}
/>
</div>
{modalState && (
<RoleModal
mode={modalState?.mode ?? 'create'}
open
role={modalState?.role}
onClose={closeModal}
onSubmit={handleSubmit}
/>
)}
</>
)
}
export default PermissionsPage

View File

@ -1,59 +0,0 @@
'use client'
import type { Role, RoleCategory } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import Row from './row'
export type RoleListGroup = {
id: string
category: RoleCategory
title: string
items: Role[]
}
export type RoleListProps = {
groups: RoleListGroup[]
className?: string
onView?: (role: Role) => void
onEdit?: (role: Role) => void
}
const RoleList = ({
groups,
className,
onView,
onEdit,
}: RoleListProps) => {
return (
<div className={cn('flex flex-col', className)}>
{groups.map((group, groupIndex) => (
<section
key={group.id}
className={cn(groupIndex > 0 && 'mt-6')}
>
<h3 className="mb-2 pr-3 system-xs-medium-uppercase tracking-wide text-text-tertiary">
{group.title}
</h3>
<div className="overflow-hidden">
{group.items.map((row, rowIndex) => (
<Row
key={row.id}
className={cn(
rowIndex > 0 && 'border-t border-divider-subtle',
)}
name={row.name}
description={row.description}
roleCategory={group.category}
role={row}
onView={onView}
onEdit={onEdit}
/>
))}
</div>
</section>
))}
</div>
)
}
export default RoleList

View File

@ -1,90 +0,0 @@
'use client'
import type { Role, RoleCategory } from '@/models/access-control'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles'
type RowMenuProps = {
roleCategory: RoleCategory
role: Role
onView?: (role: Role) => void
onEdit?: (role: Role) => void
}
const RowMenu = ({
roleCategory,
role,
onView,
onEdit,
}: RowMenuProps) => {
const [open, setOpen] = useState(false)
const handleView = useCallback(() => onView?.(role), [onView, role])
const handleEdit = useCallback(() => onEdit?.(role), [onEdit, role])
const { mutateAsync: copyRole } = useCopyWorkspaceRole()
const handleDuplicate = useCallback(() => {
copyRole(role.id, {
onSuccess: () => {
toast.success('Role duplicated successfully')
setOpen(false)
},
})
}, [copyRole, role.id])
const { mutateAsync: deleteRole } = useDeleteWorkspaceRole()
const handleDelete = useCallback(() => {
deleteRole(role.id, {
onSuccess: () => {
toast.success('Role deleted successfully')
setOpen(false)
},
})
}, [deleteRole, role.id])
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger render={<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''} aria-label="More actions" />}>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]">
{
roleCategory === 'global_system_default' && (
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleView}>
View
</DropdownMenuItem>
)
}
{
roleCategory === 'global_custom' && (
<>
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleEdit}>
Edit
</DropdownMenuItem>
<DropdownMenuItem className="system-sm-semibold text-text-secondary" onClick={handleDuplicate}>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" className="system-sm-semibold" onClick={handleDelete}>
Delete
</DropdownMenuItem>
</>
)
}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default RowMenu

View File

@ -1,50 +0,0 @@
import type { Role, RoleCategory } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
import RowMenu from './row-menu'
type RowProps = {
className?: string
name: string
description: string
roleCategory: RoleCategory
role: Role
onView?: (role: Role) => void
onEdit?: (role: Role) => void
}
const Row = ({
className,
name,
description,
roleCategory,
role,
onView,
onEdit,
}: RowProps) => {
return (
<div
className={cn(
'flex items-start gap-3 py-3.5',
className,
)}
>
<div className="min-w-0 flex-1">
<div className="system-sm-semibold text-text-secondary">
{name}
</div>
<p className="mt-1 system-sm-regular text-text-tertiary">
{description || 'No description'}
</p>
</div>
<RowMenu
roleCategory={roleCategory}
role={role}
onView={onView}
onEdit={onEdit}
/>
</div>
)
}
export default memo(Row)

View File

@ -1,18 +0,0 @@
import { useWorkspacePermissionCatalog } from '@/service/access-control/use-permission-catalog'
export const useWorkspacePermissionGroups = () => {
const { data: workspacePermissionCatalog } = useWorkspacePermissionCatalog()
const groups = workspacePermissionCatalog?.groups || []
const allPermissions = groups.flatMap(g => g.permissions) || []
const permissionMap = Object.fromEntries(
allPermissions.map(p => [p.key, p]),
)
return {
groups,
permissionMap,
}
}

View File

@ -1,161 +0,0 @@
'use client'
import type { Role } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useCallback, useState } from 'react'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import PermissionField from './permission-field'
export type RoleModalMode = 'create' | 'view' | 'edit'
export type submitRoleData = {
name: string
description?: string
permissionKeys?: string[]
}
export type RoleModalProps = {
mode: RoleModalMode
open: boolean
role?: Role
onClose: () => void
onSubmit?: (data: submitRoleData) => void
}
const TITLES: Record<RoleModalMode, { title: string, description: string }> = {
create: {
title: 'Create Role',
description: 'Create a role and assign permissions',
},
edit: {
title: 'Edit Role',
description: 'Edit role details and permissions',
},
view: {
title: 'View Role',
description: 'View role details and permissions',
},
}
const RoleModal = ({
mode,
open,
role,
onClose,
onSubmit,
}: RoleModalProps) => {
const [name, setName] = useState(role?.name ?? '')
const [desc, setDesc] = useState(role?.description ?? '')
const [permissionKeys, setPermissionKeys] = useState<string[]>(role?.permission_keys ?? [])
const readonly = mode === 'view'
const { title, description } = TITLES[mode]
const onRoleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}, [])
const onRoleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDesc(e.target.value)
}, [])
const handleSubmit = () => {
onSubmit?.({ name: name.trim(), description: desc.trim(), permissionKeys })
onClose()
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<DialogContent
className="w-[560px] overflow-visible p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{title}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{description}
</DialogDescription>
</div>
</div>
<div className="border-t border-divider-subtle" />
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-1">
<label htmlFor="role-name" className="system-sm-medium text-text-secondary">
Role name
</label>
<Input
id="role-name"
value={name}
onChange={onRoleNameChange}
placeholder="e.g. Marketing Lead"
disabled={readonly}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="role-description" className="system-sm-medium text-text-secondary">
Description
</label>
<Textarea
id="role-description"
value={desc}
onChange={onRoleDescChange}
placeholder="Describe what this role is responsible for"
disabled={readonly}
className="min-h-24 resize-none"
/>
</div>
<PermissionField
value={permissionKeys}
onChange={setPermissionKeys}
readonly={readonly}
/>
</div>
<div className="flex items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
<a
href="https://docs.dify.ai/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
<span>Learn more about permissions</span>
<span aria-hidden className="i-ri-external-link-line h-3.5 w-3.5" />
</a>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onClose}>
{readonly ? 'Close' : 'Cancel'}
</Button>
{!readonly && (
<Button
variant="primary"
disabled={!name.trim()}
onClick={handleSubmit}
>
Confirm
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default RoleModal

View File

@ -1,71 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useWorkspacePermissionGroups } from './hooks'
import PermissionPicker from './permission-picker'
export type PermissionFieldProps = {
value: string[]
onChange: (next: string[]) => void
readonly?: boolean
}
const PermissionField = ({
value,
onChange,
readonly = false,
}: PermissionFieldProps) => {
const { permissionMap } = useWorkspacePermissionGroups()
const handleRemove = (id: string) => {
onChange(value.filter(p => p !== id))
}
return (
<div className="flex flex-col gap-2">
<div className="system-sm-medium text-text-secondary">Permissions</div>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{value.map((key) => {
const p = permissionMap[key]
if (!p)
return null
return (
<span
key={key}
className={cn(
'inline-flex items-center gap-1 rounded-md bg-util-colors-indigo-indigo-50 px-1.5 py-0.5 system-xs-medium text-text-accent',
'border-[0.5px] border-components-panel-border',
)}
>
<span>{p.name}</span>
{!readonly && (
<button
type="button"
className="flex h-3.5 w-3.5 items-center justify-center rounded hover:bg-state-base-hover"
aria-label={`Remove ${p.name}`}
onClick={() => handleRemove(key)}
>
<span aria-hidden className="i-ri-close-line h-3 w-3" />
</button>
)}
</span>
)
})}
</div>
)}
{
value.length === 0 && (
<div className="system-sm-regular text-text-tertiary">
No permissions assigned yet
</div>
)
}
{!readonly && (
<PermissionPicker value={value} onChange={onChange} />
)}
</div>
)
}
export default PermissionField

View File

@ -1,177 +0,0 @@
'use client'
import type { PermissionGroup } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useEffect, useMemo, useRef, useState } from 'react'
import Checkbox from '@/app/components/base/checkbox'
import { useWorkspacePermissionGroups } from './hooks'
type PermissionPickerProps = {
value: string[]
onChange: (next: string[]) => void
className?: string
}
const PermissionPicker = ({
value,
onChange,
className,
}: PermissionPickerProps) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// Base UI Menu's FloatingFocusManager hard-codes `initialFocus: true` for top-level
// menus, which steals focus from the trigger input on open. Re-focus the input on the
// next tick so the user can keep typing to filter permissions.
useEffect(() => {
if (!open)
return
const timer = setTimeout(() => {
inputRef.current?.focus({ preventScroll: true })
}, 0)
return () => clearTimeout(timer)
}, [open])
const { groups } = useWorkspacePermissionGroups()
const filteredGroups = useMemo<PermissionGroup[]>(() => {
const q = search.trim().toLowerCase()
if (!q)
return groups
return groups
.map(group => ({
...group,
permissions: group.permissions.filter(i => i.name.toLowerCase().includes(q)),
}))
.filter(group => group.permissions.length > 0)
}, [search, groups])
const selectedSet = useMemo(() => new Set(value), [value])
const togglePermission = (id: string) => {
if (selectedSet.has(id))
onChange(value.filter(v => v !== id))
else
onChange([...value, id])
}
const getGroupState = (group: PermissionGroup) => {
const checkedCount = group.permissions.reduce(
(acc, i) => acc + (selectedSet.has(i.key) ? 1 : 0),
0,
)
return {
allChecked: checkedCount > 0 && checkedCount === group.permissions.length,
indeterminate: checkedCount > 0 && checkedCount < group.permissions.length,
}
}
const toggleGroup = (group: PermissionGroup) => {
const { allChecked, indeterminate } = getGroupState(group)
const ids = group.permissions.map(i => i.key)
if (allChecked || indeterminate) {
const idSet = new Set(ids)
onChange(value.filter(v => !idSet.has(v)))
}
else {
const next = new Set(value)
ids.forEach(id => next.add(id))
onChange(Array.from(next))
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger>
<div
className={cn(
'flex cursor-text items-center gap-2 rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-active shadow-xs ring-[0.5px] ring-components-input-border-active',
className,
)}
>
<span aria-hidden className="i-ri-search-line h-4 w-4 shrink-0 text-text-tertiary" />
<input
ref={inputRef}
className="min-w-0 grow appearance-none bg-transparent system-sm-regular text-text-primary caret-primary-600 outline-hidden placeholder:text-text-tertiary"
placeholder="Search permissions..."
value={search}
onChange={e => setSearch(e.target.value)}
onFocus={() => setOpen(true)}
onMouseDown={e => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Escape')
setOpen(false)
}}
/>
<span
aria-hidden
className={cn(
'i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary transition-transform',
open && 'rotate-180',
)}
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="max-h-80 w-[var(--anchor-width)]"
>
{filteredGroups.length === 0 && (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
No permissions found
</div>
)}
{filteredGroups.map((group) => {
const { allChecked, indeterminate } = getGroupState(group)
return (
<DropdownMenuGroup key={group.group_key}>
<button
type="button"
className="mx-1 flex h-7 w-[calc(100%-0.5rem)] items-center gap-2 rounded-lg px-2 text-left outline-hidden hover:bg-state-base-hover"
onClick={() => toggleGroup(group)}
>
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
className="pointer-events-none"
/>
<span className="system-xs-medium-uppercase tracking-wide text-text-tertiary">
{group.group_name}
</span>
</button>
{group.permissions.map((item) => {
const checked = selectedSet.has(item.key)
return (
<DropdownMenuCheckboxItem
key={item.key}
checked={checked}
onCheckedChange={() => togglePermission(item.key)}
className="gap-2 pl-6"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="system-sm-regular text-text-secondary">
{item.name}
</span>
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default PermissionPicker

View File

@ -10,7 +10,7 @@ export default function SignInLayout({ children }: any) {
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<div className={cn('flex w-full min-w-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
@ -20,7 +20,7 @@ export default function SignInLayout({ children }: any) {
)
}
>
<div className="flex flex-col md:w-[400px]">
<div className="flex w-full flex-col md:w-[400px]">
{children}
</div>
</div>

View File

@ -12,10 +12,10 @@ export default function SignInLayout({ children }: any) {
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<div className={cn('flex w-full min-w-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className="flex flex-col md:w-[400px]">
<div className="flex w-full flex-col md:w-[400px]">
{children}
</div>
</div>

View File

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

View File

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

View File

@ -216,12 +216,6 @@
"loading": "Loading",
"members.admin": "Admin",
"members.adminTip": "Can build apps & manage team settings",
"members.assignRoles": "Assign Roles",
"members.assignRolesModal.description": "Select roles to assign to this member. All permissions from selected roles will be combined.",
"members.assignRolesModal.empty": "No matching roles",
"members.assignRolesModal.searchPlaceholder": "Search roles...",
"members.assignRolesModal.selectedCount": "{{count}} selected",
"members.assignRolesModal.title": "Assign Roles",
"members.builder": "Builder",
"members.builderTip": "Can build & edit own apps",
"members.datasetOperator": "Knowledge Admin",
@ -243,25 +237,6 @@
"members.inviteTeamMemberTip": "They can access your team data directly after signing in.",
"members.invitedAsRole": "Invited as {{role}} user",
"members.lastActive": "LAST ACTIVE",
"members.memberActions": "Member actions",
"members.memberDetails.assign": "Assign",
"members.memberDetails.assignedRoles": "Assigned Roles",
"members.memberDetails.generalGroup": "GENERAL",
"members.memberDetails.openAria": "Open member details for {{name}}",
"members.memberDetails.permissions.assignRoles": "Assign roles",
"members.memberDetails.permissions.createApps": "Create apps",
"members.memberDetails.permissions.createDatasets": "Create knowledge",
"members.memberDetails.permissions.editApps": "Edit apps",
"members.memberDetails.permissions.editDatasets": "Edit knowledge",
"members.memberDetails.permissions.inviteMembers": "Invite members",
"members.memberDetails.permissions.manageBilling": "Manage billing",
"members.memberDetails.permissions.manageDatasets": "Manage knowledge",
"members.memberDetails.permissions.removeMembers": "Remove members",
"members.memberDetails.permissions.transferOwnership": "Transfer ownership",
"members.memberDetails.permissions.useApps": "Use apps",
"members.memberDetails.permissions.workspaceSettings": "Workspace settings",
"members.memberDetails.removeRoleAria": "Remove {{role}} role",
"members.memberDetails.title": "Member Details",
"members.name": "NAME",
"members.normal": "Normal",
"members.normalTip": "Only can use apps, can not build apps",
@ -632,7 +607,6 @@
"provider.saveFailed": "Save api key failed",
"provider.validatedError": "Validation failed: ",
"provider.validating": "Validating key...",
"settings.accessRules": "Access Rules",
"settings.account": "My account",
"settings.accountGroup": "GENERAL",
"settings.apiBasedExtension": "API Extension",
@ -642,7 +616,6 @@
"settings.integrations": "Integrations",
"settings.language": "Language",
"settings.members": "Members",
"settings.permissions": "Permissions",
"settings.plugin": "Plugins",
"settings.provider": "Model Provider",
"settings.workplaceGroup": "WORKSPACE",

View File

@ -216,12 +216,6 @@
"loading": "加载中",
"members.admin": "管理员",
"members.adminTip": "能够建立应用程序和管理团队设置",
"members.assignRoles": "分配角色",
"members.assignRolesModal.description": "为该成员选择要分配的角色,所选角色的权限将被合并。",
"members.assignRolesModal.empty": "没有匹配的角色",
"members.assignRolesModal.searchPlaceholder": "搜索角色…",
"members.assignRolesModal.selectedCount": "已选 {{count}} 项",
"members.assignRolesModal.title": "分配角色",
"members.builder": "构建器",
"members.builderTip": "可以构建和编辑自己的应用程序",
"members.datasetOperator": "知识库管理员",
@ -243,25 +237,6 @@
"members.inviteTeamMemberTip": "对方在登录后可以访问你的团队数据。",
"members.invitedAsRole": "邀请为{{role}}用户",
"members.lastActive": "上次活动时间",
"members.memberActions": "成员操作",
"members.memberDetails.assign": "分配",
"members.memberDetails.assignedRoles": "已分配角色",
"members.memberDetails.generalGroup": "通用角色",
"members.memberDetails.openAria": "打开 {{name}} 的成员详情",
"members.memberDetails.permissions.assignRoles": "分配角色",
"members.memberDetails.permissions.createApps": "创建应用",
"members.memberDetails.permissions.createDatasets": "创建知识库",
"members.memberDetails.permissions.editApps": "编辑应用",
"members.memberDetails.permissions.editDatasets": "编辑知识库",
"members.memberDetails.permissions.inviteMembers": "邀请成员",
"members.memberDetails.permissions.manageBilling": "管理订阅",
"members.memberDetails.permissions.manageDatasets": "管理知识库",
"members.memberDetails.permissions.removeMembers": "移除成员",
"members.memberDetails.permissions.transferOwnership": "转移所有权",
"members.memberDetails.permissions.useApps": "使用应用",
"members.memberDetails.permissions.workspaceSettings": "工作空间设置",
"members.memberDetails.removeRoleAria": "移除 {{role}} 角色",
"members.memberDetails.title": "成员详情",
"members.name": "姓名",
"members.normal": "成员",
"members.normalTip": "只能使用应用程序,不能建立应用程序",
@ -632,7 +607,6 @@
"provider.saveFailed": "API 密钥保存失败",
"provider.validatedError": "校验失败:",
"provider.validating": "验证密钥中...",
"settings.accessRules": "访问规则",
"settings.account": "我的账户",
"settings.accountGroup": "通用",
"settings.apiBasedExtension": "API 扩展",
@ -642,7 +616,6 @@
"settings.integrations": "集成",
"settings.language": "语言",
"settings.members": "成员",
"settings.permissions": "权限",
"settings.plugin": "插件",
"settings.provider": "模型供应商",
"settings.workplaceGroup": "工作空间",

View File

@ -1,18 +1,14 @@
export const SubjectType = {
GROUP: 'group',
ACCOUNT: 'account',
} as const
export enum SubjectType {
GROUP = 'group',
ACCOUNT = 'account',
}
export type SubjectType = typeof SubjectType[keyof typeof SubjectType]
export const AccessMode = {
PUBLIC: 'public',
SPECIFIC_GROUPS_MEMBERS: 'private',
ORGANIZATION: 'private_all',
EXTERNAL_MEMBERS: 'sso_verified',
} as const
export type AccessMode = typeof AccessMode[keyof typeof AccessMode]
export enum AccessMode {
PUBLIC = 'public',
SPECIFIC_GROUPS_MEMBERS = 'private',
ORGANIZATION = 'private_all',
EXTERNAL_MEMBERS = 'sso_verified',
}
export type AccessControlGroup = {
id: 'string'
@ -32,150 +28,3 @@ export type SubjectGroup = { subjectId: string, subjectType: SubjectType, groupD
export type SubjectAccount = { subjectId: string, subjectType: SubjectType, accountData: AccessControlAccount }
export type Subject = SubjectGroup | SubjectAccount
export type Permission = {
key: string
name: string
description: string
}
export type PermissionGroup = {
group_key: string
group_name: string
description: string
permissions: Permission[]
}
export type PermissionGroups = {
groups: PermissionGroup[]
}
export type PermissionKey = string
export type RoleType = 'workspace' | 'app' | 'dataset'
export type RoleCategory = 'global_system_default' | 'global_custom'
export type Role = {
id: string
tenant_id: string
type: RoleType
category: RoleCategory
name: string
description: string
is_builtin: boolean
permission_keys: PermissionKey[]
}
export type Pagination = {
total_count: number
per_page: number
current_page: number
total_pages: number
}
export type PaginationParameters = {
page?: number
limit?: number
reverse?: boolean
}
export type RoleListResponse = {
data: Role[]
pagination: Pagination
}
export type CreateRoleRequest = {
name: string
description?: string
permission_keys?: PermissionKey[]
}
export type UpdateRolesRequest = {
id: string
name: string
description?: string
permission_keys?: PermissionKey[]
}
export type AccessPolicyResourceType = 'app' | 'dataset'
export type AccessPolicyCategory = 'global_system_default' | 'global_custom'
export type AccessPolicy = {
id: string
tenant_id: string
resource_type: AccessPolicyResourceType
policy_key: string
name: string
description: string
permission_keys: PermissionKey[]
is_builtin: boolean
category: AccessPolicyCategory
created_at: string
updated_at: string
}
export type CreateAccessPolicyRequest = {
name: string
description?: string
permission_keys?: PermissionKey[]
}
export type UpdateAccessPolicyRequest = {
id: string
name: string
description?: string
permission_keys?: PermissionKey[]
}
export type BindingType = 'role' | 'account'
export type Bindings = {
role_ids: Array<{
id: string
name: string
}>
account_ids: Array<{
id: string
name: string
}>
}
export type BindingsPayload = {
role_ids: string[]
account_ids: string[]
}
export type AccessPolicyWithBindings = {
policy: AccessPolicy
} & Bindings
export type GetAppAccessPolicyByAppIdResponse = {
app_id: string
items: AccessPolicyWithBindings[]
}
export type GetDatasetAccessPolicyByDatasetIdResponse = {
dataset_id: string
items: AccessPolicyWithBindings[]
}
export type GetAppAccessPoliciesResponse = {
items: AccessPolicyWithBindings[]
pagination: Pagination
}
export type GetDatasetAccessPoliciesResponse = {
items: AccessPolicyWithBindings[]
pagination: Pagination
}
export type UpdateRolesOfMemberRequest = {
member_id: string
role_ids: string[]
}
export type UpdateRolesOfMemberResponse = {
account_id: string
roles: Role[]
}

View File

@ -2,8 +2,8 @@ import type { AccessControlAccount, AccessControlGroup, AccessMode, Subject } fr
import type { App } from '@/types/app'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { get, post } from '../base'
import { getUserCanAccess } from '../share'
import { get, post } from './base'
import { getUserCanAccess } from './share'
const NAME_SPACE = 'access-control'
@ -24,25 +24,16 @@ type SearchResults = {
hasMore: boolean
}
type SearchForWhiteListCandidatesQuery = {
keyword?: string
groupId?: AccessControlGroup['id']
resultsPerPage?: number
}
export const useSearchForWhiteListCandidates = (query: SearchForWhiteListCandidatesQuery, enabled: boolean) => {
const { keyword, groupId, resultsPerPage } = query
export const useSearchForWhiteListCandidates = (query: { keyword?: string, groupId?: AccessControlGroup['id'], resultsPerPage?: number }, enabled: boolean) => {
return useInfiniteQuery({
queryKey: [NAME_SPACE, 'app-whitelist-candidates', keyword, groupId, resultsPerPage],
queryKey: [NAME_SPACE, 'app-whitelist-candidates', query],
queryFn: ({ pageParam }) => {
const params = new URLSearchParams()
if (keyword)
params.append('keyword', keyword)
if (groupId)
params.append('groupId', groupId)
if (resultsPerPage)
params.append('resultsPerPage', `${resultsPerPage}`)
Object.keys(query).forEach((key) => {
const typedKey = key as keyof typeof query
if (query[typedKey])
params.append(key, `${query[typedKey]}`)
})
params.append('pageNumber', `${pageParam}`)
return get<SearchResults>(`/enterprise/webapp/app/subject/search?${new URLSearchParams(params).toString()}`)
},

View File

@ -1,30 +0,0 @@
import type {
PermissionGroups,
} from '@/models/access-control'
import { useQuery } from '@tanstack/react-query'
import { get } from '../base'
const NAME_SPACE = 'rbac-permission-catalog'
export const useWorkspacePermissionCatalog = () => {
return useQuery({
queryKey: [NAME_SPACE, 'workspace'],
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog'),
})
}
export const useAppPermissionCatalog = (enabled?: boolean) => {
return useQuery({
queryKey: [NAME_SPACE, 'app'],
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/app'),
enabled: enabled ?? true,
})
}
export const useDatasetPermissionCatalog = (enabled?: boolean) => {
return useQuery({
queryKey: [NAME_SPACE, 'dataset'],
queryFn: () => get<PermissionGroups>('/workspaces/current/rbac/role-permissions/catalog/dataset'),
enabled: enabled ?? true,
})
}

Some files were not shown because too many files have changed in this diff Show More