From c232375fd218ab241c1bf65f0b04bba66c69f96b Mon Sep 17 00:00:00 2001 From: kota-maeda0708 Date: Sun, 21 Jun 2026 18:21:57 +0900 Subject: [PATCH] feat: add visibility control for plugin datasource credentials Plugin-based datasource credentials (Notion, Jina, Firecrawl, etc.) were implicitly shared with every workspace member. PR #35468 added the visibility column, creator tracking and read-side filtering for datasource providers, but left no way to actually set or change a credential's scope, so every datasource credential stayed effectively all_team_members. This completes the datasource side: Backend: - add_datasource_api_key_provider / add_datasource_oauth_provider accept user_id and visibility; API keys default to all_team_members, OAuth defaults to only_me (matching the plugin-credential philosophy) - new update_datasource_credential_visibility (+ console endpoint) so the creator can switch between only_me / all_team_members / partial_members; only the creator (or legacy NULL-owner rows) may change the scope - replace_partial_member_list / clear_partial_member_list helpers on CredentialPermissionService (caller owns the transaction) - list_datasource_credentials returns visibility, user_id, is_editable and partial_member_list Frontend: - VisibilityModal reusing PermissionSelector, with an empty-partial-members guard (the backend rejects an empty list) - "Who can use" action in the credential operator, gated on is_editable - scope badges (only me / partial team members) on the credential item Existing credentials keep working: the visibility column defaults to all_team_members and legacy rows with a NULL owner are always visible. --- .../datasets/rag_pipeline/datasource_auth.py | 58 ++++++- api/services/credential_permission_service.py | 43 +++++- api/services/datasource_provider_service.py | 121 ++++++++++++++- .../test_credential_permission_service.py | 41 +++++ .../test_datasource_provider_service.py | 81 ++++++++++ .../__tests__/item.spec.tsx | 37 +++++ .../__tests__/operator.spec.tsx | 47 ++++++ .../__tests__/visibility-modal.spec.tsx | 144 ++++++++++++++++++ .../data-source-page-new/card.tsx | 17 +++ .../data-source-page-new/item.tsx | 16 ++ .../data-source-page-new/operator.tsx | 6 + .../data-source-page-new/types.ts | 8 + .../data-source-page-new/visibility-modal.tsx | 118 ++++++++++++++ web/service/use-datasource.ts | 19 ++- 14 files changed, 742 insertions(+), 14 deletions(-) create mode 100644 web/app/components/header/account-setting/data-source-page-new/__tests__/visibility-modal.spec.tsx create mode 100644 web/app/components/header/account-setting/data-source-page-new/visibility-modal.tsx diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index a575760ee19..87b598ac511 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -34,12 +34,21 @@ from services.plugin.oauth_service import OAuthProxyService class DatasourceCredentialPayload(BaseModel): name: str | None = Field(default=None, max_length=100) credentials: dict[str, Any] + visibility: str | None = Field(default=None, description="only_me or all_team_members (defaults to all_team)") class DatasourceCredentialDeletePayload(BaseModel): credential_id: str +class DatasourceCredentialVisibilityPayload(BaseModel): + credential_id: str + visibility: str = Field(description="only_me, all_team_members, or partial_members") + partial_member_list: list[str] | None = Field( + default=None, description="account ids granted access when visibility is partial_members" + ) + + class DatasourceCredentialUpdatePayload(BaseModel): credential_id: str name: str | None = Field(default=None, max_length=100) @@ -81,6 +90,7 @@ register_schema_models( DatasourceOAuthCallbackQuery, DatasourceCredentialPayload, DatasourceCredentialDeletePayload, + DatasourceCredentialVisibilityPayload, DatasourceCredentialUpdatePayload, DatasourceCustomClientPayload, DatasourceDefaultPayload, @@ -210,6 +220,9 @@ class DatasourceOAuthCallback(Resource): name=oauth_response.metadata.get("name") or None, expire_at=oauth_response.expires_at, credentials=dict(oauth_response.credentials), + user_id=user_id, + # OAuth tokens are tied to the authorizing user's personal account; default to only_me + visibility="only_me", ) return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") @@ -223,8 +236,9 @@ class DatasourceAuth(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_CREATE, resource_required=False) + @with_current_user @with_current_tenant_id - def post(self, current_tenant_id: str, provider_id: str): + def post(self, current_tenant_id: str, current_user: Account, provider_id: str): payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {}) datasource_provider_id = DatasourceProviderID(provider_id) datasource_provider_service = DatasourceProviderService() @@ -235,6 +249,8 @@ class DatasourceAuth(Resource): provider_id=datasource_provider_id, credentials=payload.credentials, name=payload.name, + user_id=current_user.id, + visibility=payload.visibility, ) except CredentialsValidateFailedError as ex: raise ValueError(str(ex)) @@ -317,10 +333,13 @@ class DatasourceAuthListApi(Resource): @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def get(self, current_tenant_id: str): + def get(self, current_tenant_id: str, current_user: Account): datasource_provider_service = DatasourceProviderService() - datasources = datasource_provider_service.get_all_datasource_credentials(tenant_id=current_tenant_id) + datasources = datasource_provider_service.get_all_datasource_credentials( + tenant_id=current_tenant_id, user=current_user + ) return {"result": jsonable_encoder(datasources)}, 200 @@ -330,10 +349,13 @@ class DatasourceHardCodeAuthListApi(Resource): @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def get(self, current_tenant_id: str): + def get(self, current_tenant_id: str, current_user: Account): datasource_provider_service = DatasourceProviderService() - datasources = datasource_provider_service.get_hard_code_datasource_credentials(tenant_id=current_tenant_id) + datasources = datasource_provider_service.get_hard_code_datasource_credentials( + tenant_id=current_tenant_id, user=current_user + ) return {"result": jsonable_encoder(datasources)}, 200 @@ -417,3 +439,29 @@ class DatasourceUpdateProviderNameApi(Resource): credential_id=payload.credential_id, ) return {"result": "success"}, 200 + + +@console_ns.route("/auth/plugin/datasource//visibility") +class DatasourceUpdateVisibilityApi(Resource): + @console_ns.expect(console_ns.models[DatasourceCredentialVisibilityPayload.__name__]) + @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) + @with_current_user + @with_current_tenant_id + def post(self, current_tenant_id: str, current_user: Account, provider_id: str): + payload = DatasourceCredentialVisibilityPayload.model_validate(console_ns.payload or {}) + datasource_provider_id = DatasourceProviderID(provider_id) + datasource_provider_service = DatasourceProviderService() + datasource_provider_service.update_datasource_credential_visibility( + tenant_id=current_tenant_id, + datasource_provider_id=datasource_provider_id, + credential_id=payload.credential_id, + visibility=payload.visibility, + partial_member_list=payload.partial_member_list, + user=current_user, + ) + return {"result": "success"}, 200 diff --git a/api/services/credential_permission_service.py b/api/services/credential_permission_service.py index 21806e0178a..bd033e1a53a 100644 --- a/api/services/credential_permission_service.py +++ b/api/services/credential_permission_service.py @@ -1,6 +1,6 @@ from collections.abc import Sequence -from sqlalchemy import or_, select +from sqlalchemy import delete, or_, select from sqlalchemy.orm import InstrumentedAttribute from extensions.ext_database import db @@ -26,6 +26,47 @@ class CredentialPermissionService: ) ).all() + @classmethod + def replace_partial_member_list( + cls, + *, + session, + credential_id: str, + credential_type: str, + tenant_id: str, + account_ids: Sequence[str], + ) -> None: + """Replace the partial-member access list of a credential. + + Mirrors DatasetPermissionService.update_partial_member_list. The caller owns + the transaction (``session``) so this stays consistent with the visibility update. + """ + session.execute( + delete(CredentialPermission).where( + CredentialPermission.credential_id == credential_id, + CredentialPermission.credential_type == credential_type, + ) + ) + for account_id in dict.fromkeys(account_ids): + session.add( + CredentialPermission( + credential_id=credential_id, + credential_type=credential_type, + account_id=account_id, + tenant_id=tenant_id, + ) + ) + + @classmethod + def clear_partial_member_list(cls, *, session, credential_id: str, credential_type: str) -> None: + """Remove all partial-member access rows of a credential (caller owns the transaction).""" + session.execute( + delete(CredentialPermission).where( + CredentialPermission.credential_id == credential_id, + CredentialPermission.credential_type == credential_type, + ) + ) + @classmethod def apply_visibility_filter( cls, diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index 12807a41f04..43843c2c1f8 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import Forbidden from configs import dify_config from constants import HIDDEN_VALUE, UNKNOWN_VALUE @@ -22,8 +23,11 @@ from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncry from extensions.ext_database import db from extensions.ext_redis import redis_client from graphon.model_runtime.entities.provider_entities import FormType +from models.credential_permission import CredentialType as CredPermType +from models.enums import PermissionEnum from models.oauth import DatasourceOauthParamConfig, DatasourceOauthTenantParamConfig, DatasourceProvider from models.provider_ids import DatasourceProviderID +from services.credential_permission_service import CredentialPermissionService logger = logging.getLogger(__name__) @@ -59,6 +63,22 @@ class DatasourceProviderService: return False return (datasource_provider.expires_at - 60) < current_time + @staticmethod + def _resolve_visibility( + visibility: str | None, default: PermissionEnum = PermissionEnum.ALL_TEAM + ) -> PermissionEnum: + """Convert a raw visibility string to PermissionEnum, falling back to ``default``. + + ``None`` keeps the legacy behaviour (all_team_members) for API backward compatibility; + the UI sends an explicit value when the creator picks a narrower scope. + """ + if not visibility: + return default + try: + return PermissionEnum(visibility) + except ValueError: + raise ValueError(f"Invalid visibility: {visibility}") + def _refresh_datasource_credentials( self, tenant_id: str, @@ -382,6 +402,66 @@ class DatasourceProviderService: target_provider.is_default = True return {"result": "success"} + def update_datasource_credential_visibility( + self, + tenant_id: str, + datasource_provider_id: DatasourceProviderID, + credential_id: str, + visibility: str, + user: "Account", + partial_member_list: list[str] | None = None, + ) -> None: + """ + Update the visibility (sharing scope) of a datasource credential. + + - only_me / all_team_members: clears any partial-member rows + - partial_members: replaces the partial-member access list (requires a non-empty list) + + Only the creator may change visibility. Legacy credentials (user_id is NULL) have no + recorded creator; the first user to set a non-public scope claims ownership so the + visibility filter can enforce it (a NULL user_id is otherwise treated as all_team). + """ + visibility_enum = self._resolve_visibility(visibility) + with sessionmaker(bind=db.engine).begin() as session: + target_provider = session.scalar( + select(DatasourceProvider) + .where( + DatasourceProvider.tenant_id == tenant_id, + DatasourceProvider.id == credential_id, + DatasourceProvider.provider == datasource_provider_id.provider_name, + DatasourceProvider.plugin_id == datasource_provider_id.plugin_id, + ) + .limit(1) + ) + if target_provider is None: + raise ValueError("provider not found") + + if target_provider.user_id is not None and target_provider.user_id != user.id: + raise Forbidden("Only the creator can change the visibility of this credential") + + if visibility_enum == PermissionEnum.PARTIAL_TEAM: + members = list(dict.fromkeys(partial_member_list or [])) + if not members: + raise ValueError("partial_member_list is required when visibility is partial_members") + CredentialPermissionService.replace_partial_member_list( + session=session, + credential_id=credential_id, + credential_type=CredPermType.DATASOURCE_PROVIDER, + tenant_id=tenant_id, + account_ids=members, + ) + else: + CredentialPermissionService.clear_partial_member_list( + session=session, + credential_id=credential_id, + credential_type=CredPermType.DATASOURCE_PROVIDER, + ) + + # Record ownership so only_me / partial_members can actually be enforced. + if target_provider.user_id is None and visibility_enum != PermissionEnum.ALL_TEAM: + target_provider.user_id = user.id + target_provider.visibility = visibility_enum + def setup_oauth_custom_client_params( self, tenant_id: str, @@ -635,9 +715,14 @@ class DatasourceProviderService: avatar_url: str | None, expire_at: int, credentials: dict[str, Any], + user_id: str | None = None, + visibility: str | None = None, ) -> None: """ add datasource oauth provider + + :param user_id: creator account id, persisted so visibility can be enforced later + :param visibility: "only_me" or "all_team_members" (OAuth tokens default to only_me) """ credential_type = CredentialType.OAUTH2 with sessionmaker(bind=db.engine).begin() as session: @@ -694,6 +779,8 @@ class DatasourceProviderService: encrypted_credentials=credentials, avatar_url=avatar_url or "default", expires_at=expire_at, + user_id=user_id, + visibility=self._resolve_visibility(visibility, default=PermissionEnum.ONLY_ME), ) session.add(datasource_provider) @@ -703,6 +790,8 @@ class DatasourceProviderService: tenant_id: str, provider_id: DatasourceProviderID, credentials: dict[str, Any], + user_id: str | None = None, + visibility: str | None = None, ) -> None: """ validate datasource provider credentials. @@ -710,6 +799,8 @@ class DatasourceProviderService: :param tenant_id: :param provider: :param credentials: + :param user_id: creator account id, persisted so visibility can be enforced later + :param visibility: "only_me" or "all_team_members" (defaults to all_team_members) """ provider_name = provider_id.provider_name plugin_id = provider_id.plugin_id @@ -764,6 +855,8 @@ class DatasourceProviderService: plugin_id=plugin_id, auth_type=CredentialType.API_KEY, encrypted_credentials=credentials, + user_id=user_id, + visibility=self._resolve_visibility(visibility), ) session.add(datasource_provider) @@ -811,9 +904,6 @@ class DatasourceProviderService: :param user: current user (id + admin flag drive the visibility filter) :return: """ - from models.credential_permission import CredentialType as CredPermType - from services.credential_permission_service import CredentialPermissionService - # Get all provider configurations of the current workspace query = select(DatasourceProvider).where( DatasourceProvider.tenant_id == tenant_id, @@ -857,6 +947,14 @@ class DatasourceProviderService: for key, value in copy_credentials.items(): if key in credential_secret_variables: copy_credentials[key] = encrypter.obfuscated_token(value) + visibility = datasource_provider.visibility or PermissionEnum.ALL_TEAM + partial_member_list: list[str] = [] + if visibility == PermissionEnum.PARTIAL_TEAM: + partial_member_list = list( + CredentialPermissionService.get_partial_member_list( + datasource_provider.id, CredPermType.DATASOURCE_PROVIDER + ) + ) copy_credentials_list.append( { "credential": copy_credentials, @@ -865,15 +963,23 @@ class DatasourceProviderService: "avatar_url": datasource_provider.avatar_url, "id": datasource_provider.id, "is_default": default_provider_id and datasource_provider.id == default_provider_id, + "visibility": str(visibility), + "user_id": datasource_provider.user_id, + # only the creator may change a credential's scope (legacy NULL-owner rows stay editable) + "is_editable": True + if user is None + else (datasource_provider.user_id is None or datasource_provider.user_id == user.id), + "partial_member_list": partial_member_list, } ) return copy_credentials_list - def get_all_datasource_credentials(self, tenant_id: str) -> list[dict]: + def get_all_datasource_credentials(self, tenant_id: str, user: "Account | None" = None) -> list[dict]: """ get datasource credentials. + :param user: current user; when provided the credential list is filtered by visibility :return: """ # get all plugin providers @@ -883,7 +989,7 @@ class DatasourceProviderService: for datasource in datasources: datasource_provider_id = DatasourceProviderID(f"{datasource.plugin_id}/{datasource.provider}") credentials = self.list_datasource_credentials( - tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id + tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id, user=user ) redirect_uri = ( f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{datasource_provider_id}/datasource/callback" @@ -926,10 +1032,11 @@ class DatasourceProviderService: ) return datasource_credentials - def get_hard_code_datasource_credentials(self, tenant_id: str) -> list[dict]: + def get_hard_code_datasource_credentials(self, tenant_id: str, user: "Account | None" = None) -> list[dict]: """ get hard code datasource credentials. + :param user: current user; when provided the credential list is filtered by visibility :return: """ # get all plugin providers @@ -945,7 +1052,7 @@ class DatasourceProviderService: ]: datasource_provider_id = DatasourceProviderID(f"{datasource.plugin_id}/{datasource.provider}") credentials = self.list_datasource_credentials( - tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id + tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id, user=user ) redirect_uri = "{}/console/api/oauth/plugin/{}/datasource/callback".format( dify_config.CONSOLE_API_URL, datasource_provider_id diff --git a/api/tests/unit_tests/services/test_credential_permission_service.py b/api/tests/unit_tests/services/test_credential_permission_service.py index 687d6880178..5a63036f073 100644 --- a/api/tests/unit_tests/services/test_credential_permission_service.py +++ b/api/tests/unit_tests/services/test_credential_permission_service.py @@ -101,3 +101,44 @@ class TestApplyVisibilityFilter: assert "WHERE" in compiled assert "visibility" in compiled assert "user_id" in compiled + + +class TestReplacePartialMemberList: + def test_replaces_with_deduped_members(self, credential_id, tenant_id, user_id, other_user_id): + session = MagicMock() + CredentialPermissionService.replace_partial_member_list( + session=session, + credential_id=credential_id, + credential_type=CredentialType.DATASOURCE_PROVIDER, + tenant_id=tenant_id, + account_ids=[user_id, other_user_id, user_id], # duplicate user_id + ) + # one delete + one add per unique member + assert session.execute.call_count == 1 + added = [c.args[0] for c in session.add.call_args_list] + assert {a.account_id for a in added} == {user_id, other_user_id} + assert all(a.credential_type == CredentialType.DATASOURCE_PROVIDER for a in added) + + def test_empty_list_only_deletes(self, credential_id, tenant_id): + session = MagicMock() + CredentialPermissionService.replace_partial_member_list( + session=session, + credential_id=credential_id, + credential_type=CredentialType.DATASOURCE_PROVIDER, + tenant_id=tenant_id, + account_ids=[], + ) + assert session.execute.call_count == 1 + session.add.assert_not_called() + + +class TestClearPartialMemberList: + def test_clear_issues_delete(self, credential_id): + session = MagicMock() + CredentialPermissionService.clear_partial_member_list( + session=session, + credential_id=credential_id, + credential_type=CredentialType.DATASOURCE_PROVIDER, + ) + assert session.execute.call_count == 1 + session.add.assert_not_called() diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index f374a294825..742303b2bed 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -418,6 +418,87 @@ class TestDatasourceProviderService: service.set_default_datasource_provider("t1", make_id(), "new-id") assert target.is_default is True + # ----------------------------------------------------------------------- + # _resolve_visibility + # ----------------------------------------------------------------------- + + def test_should_default_to_all_team_when_visibility_is_none(self, service): + from models.enums import PermissionEnum + + assert service._resolve_visibility(None) == PermissionEnum.ALL_TEAM + + def test_should_use_explicit_default_when_visibility_is_none(self, service): + from models.enums import PermissionEnum + + assert service._resolve_visibility(None, default=PermissionEnum.ONLY_ME) == PermissionEnum.ONLY_ME + + def test_should_parse_valid_visibility_string(self, service): + from models.enums import PermissionEnum + + assert service._resolve_visibility("only_me") == PermissionEnum.ONLY_ME + assert service._resolve_visibility("partial_members") == PermissionEnum.PARTIAL_TEAM + + def test_should_raise_when_visibility_invalid(self, service): + with pytest.raises(ValueError, match="Invalid visibility"): + service._resolve_visibility("bogus") + + # ----------------------------------------------------------------------- + # update_datasource_credential_visibility + # ----------------------------------------------------------------------- + + def test_should_raise_when_visibility_target_provider_not_found(self, service, mock_db_session): + mock_db_session.scalar.return_value = None + user = MagicMock(id="u1") + with pytest.raises(ValueError, match="not found"): + service.update_datasource_credential_visibility("t1", make_id(), "cred-id", "only_me", user) + + def test_should_forbid_non_creator_changing_visibility(self, service, mock_db_session): + from werkzeug.exceptions import Forbidden + + target = MagicMock(spec=DatasourceProvider) + target.user_id = "creator" + mock_db_session.scalar.return_value = target + user = MagicMock(id="someone-else") + with pytest.raises(Forbidden): + service.update_datasource_credential_visibility("t1", make_id(), "cred-id", "only_me", user) + + def test_should_require_member_list_for_partial_members(self, service, mock_db_session): + target = MagicMock(spec=DatasourceProvider) + target.user_id = "u1" + mock_db_session.scalar.return_value = target + user = MagicMock(id="u1") + with pytest.raises(ValueError, match="partial_member_list is required"): + service.update_datasource_credential_visibility( + "t1", make_id(), "cred-id", "partial_members", user, partial_member_list=[] + ) + + def test_should_claim_ownership_when_legacy_row_set_to_only_me(self, service, mock_db_session): + from models.enums import PermissionEnum + + target = MagicMock(spec=DatasourceProvider) + target.user_id = None # legacy row with no recorded creator + mock_db_session.scalar.return_value = target + user = MagicMock(id="u1") + with patch("services.datasource_provider_service.CredentialPermissionService") as mock_cps: + service.update_datasource_credential_visibility("t1", make_id(), "cred-id", "only_me", user) + mock_cps.clear_partial_member_list.assert_called_once() + assert target.user_id == "u1" + assert target.visibility == PermissionEnum.ONLY_ME + + def test_should_replace_members_when_set_to_partial(self, service, mock_db_session): + from models.enums import PermissionEnum + + target = MagicMock(spec=DatasourceProvider) + target.user_id = "u1" + mock_db_session.scalar.return_value = target + user = MagicMock(id="u1") + with patch("services.datasource_provider_service.CredentialPermissionService") as mock_cps: + service.update_datasource_credential_visibility( + "t1", make_id(), "cred-id", "partial_members", user, partial_member_list=["a", "b"] + ) + mock_cps.replace_partial_member_list.assert_called_once() + assert target.visibility == PermissionEnum.PARTIAL_TEAM + # ----------------------------------------------------------------------- # get_oauth_encrypter (lines 404-420) # ----------------------------------------------------------------------- diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx index fdcb954c145..be272a8994f 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/item.spec.tsx @@ -1,6 +1,7 @@ import type { DataSourceCredential } from '../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { PermissionLevel } from '@/models/permission' import Item from '../item' /** @@ -64,6 +65,42 @@ describe('Item Component', () => { }) }) + describe('Visibility Badge', () => { + it('should show the "only me" badge when visibility is only_me', () => { + render( + , + ) + + expect(screen.getByText('datasetSettings.form.permissionsOnlyMe')).toBeInTheDocument() + }) + + it('should show the "invited members" badge when visibility is partial_members', () => { + render( + , + ) + + expect(screen.getByText('datasetSettings.form.permissionsInvitedMembers')).toBeInTheDocument() + }) + + it('should not show a scope badge for the default all_team_members visibility', () => { + render( + , + ) + + expect(screen.queryByText('datasetSettings.form.permissionsOnlyMe')).not.toBeInTheDocument() + expect(screen.queryByText('datasetSettings.form.permissionsInvitedMembers')).not.toBeInTheDocument() + }) + }) + describe('Rename Mode Interactions', () => { it('should switch to rename mode when Trigger Rename is clicked', async () => { // Arrange diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx index 1cbe3717b0d..c32f5898220 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/operator.spec.tsx @@ -164,4 +164,51 @@ describe('Operator Component', () => { }) }) }) + + describe('Visibility Action', () => { + it('should call onAction for "visibility" when the credential is editable and manageable', async () => { + // Arrange + const credential = createMockCredential(CredentialTypeEnum.API_KEY) + render() + + // Act + await userEvent.setup().click(screen.getByRole('button')) + fireEvent.click(await screen.findByText('plugin.auth.whoCanUse')) + + // Assert + await waitFor(() => { + expect(mockOnAction).toHaveBeenCalledWith('visibility', credential) + }) + }) + + it('should not call onAction for "visibility" when the credential is not editable (legacy/non-owner row)', async () => { + // Arrange: is_editable === false means the current user does not own the credential + const credential = { ...createMockCredential(CredentialTypeEnum.API_KEY), is_editable: false } + render() + + // Act + await userEvent.setup().click(screen.getByRole('button')) + fireEvent.click(await screen.findByText('plugin.auth.whoCanUse')) + + // Assert + await waitFor(() => { + expect(mockOnAction).not.toHaveBeenCalled() + }) + }) + + it('should not call onAction for "visibility" when the user cannot manage credentials', async () => { + // Arrange + const credential = createMockCredential(CredentialTypeEnum.API_KEY) + render() + + // Act + await userEvent.setup().click(screen.getByRole('button')) + fireEvent.click(await screen.findByText('plugin.auth.whoCanUse')) + + // Assert + await waitFor(() => { + expect(mockOnAction).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/visibility-modal.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/visibility-modal.spec.tsx new file mode 100644 index 00000000000..c1726184949 --- /dev/null +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/visibility-modal.spec.tsx @@ -0,0 +1,144 @@ +import type { DataSourceCredential } from '../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import { PermissionLevel } from '@/models/permission' +import VisibilityModal from '../visibility-modal' + +/** + * VisibilityModal Component Tests + * + * Covers the new credential-sharing-scope editing flow: + * - payload shaping per permission level (partial_member_list only for partial_members) + * - the empty-partial-members guard (the backend rejects an empty list) + * - success side effects (onUpdate + onClose) and cancel handling + */ + +const mockUpdateVisibility = vi.fn() + +vi.mock('@/service/use-datasource', () => ({ + useUpdateDataSourceCredentialVisibility: () => ({ mutateAsync: mockUpdateVisibility }), +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: () => ({ + data: { + accounts: [ + { id: 'm1', name: 'Member One', email: 'm1@example.com', role: 'editor' }, + { id: 'm2', name: 'Member Two', email: 'm2@example.com', role: 'editor' }, + ], + }, + }), +})) + +// app-context is a complex provider; PermissionSelector only needs userProfile from it. +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { userProfile: { id: string, name: string, email: string } }) => unknown) => + selector({ userProfile: { id: 'owner', name: 'Owner', email: 'owner@example.com' } }), +})) + +describe('VisibilityModal Component', () => { + const mockOnClose = vi.fn() + const mockOnUpdate = vi.fn() + + const createCredential = (overrides: Partial = {}): DataSourceCredential => ({ + id: 'cred-1', + name: 'Test Credential', + credential: {}, + type: CredentialTypeEnum.API_KEY, + is_default: false, + avatar_url: '', + visibility: PermissionLevel.allTeamMembers, + ...overrides, + }) + + const renderModal = (credentialOverrides: Partial = {}) => + render( + , + ) + + beforeEach(() => { + vi.clearAllMocks() + mockUpdateVisibility.mockResolvedValue(undefined) + }) + + describe('Rendering', () => { + it('should render the dialog title and save button', () => { + renderModal() + + expect(screen.getByText('plugin.auth.whoCanUse')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + }) + }) + + describe('Save payload shaping', () => { + it('should submit without partial_member_list for all_team_members', async () => { + renderModal({ visibility: PermissionLevel.allTeamMembers }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => { + expect(mockUpdateVisibility).toHaveBeenCalledWith({ + credential_id: 'cred-1', + visibility: PermissionLevel.allTeamMembers, + }) + }) + }) + + it('should submit partial_member_list for partial_members', async () => { + renderModal({ visibility: PermissionLevel.partialMembers, partial_member_list: ['m1'] }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => { + expect(mockUpdateVisibility).toHaveBeenCalledWith({ + credential_id: 'cred-1', + visibility: PermissionLevel.partialMembers, + partial_member_list: ['m1'], + }) + }) + }) + }) + + describe('Empty partial members guard', () => { + it('should disable save when partial_members is selected with no members', () => { + renderModal({ visibility: PermissionLevel.partialMembers, partial_member_list: [] }) + + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + }) + + it('should not call the update API when the guard blocks submission', () => { + renderModal({ visibility: PermissionLevel.partialMembers, partial_member_list: [] }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(mockUpdateVisibility).not.toHaveBeenCalled() + }) + }) + + describe('Side effects', () => { + it('should call onUpdate and onClose after a successful save', async () => { + renderModal({ visibility: PermissionLevel.allTeamMembers }) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => { + expect(mockOnUpdate).toHaveBeenCalledTimes(1) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + }) + + it('should close without saving when cancel is clicked', () => { + renderModal() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + expect(mockUpdateVisibility).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/data-source-page-new/card.tsx b/web/app/components/header/account-setting/data-source-page-new/card.tsx index a972893e7ec..ee2a34c50e2 100644 --- a/web/app/components/header/account-setting/data-source-page-new/card.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/card.tsx @@ -15,6 +15,7 @@ import { memo, useCallback, useRef, + useState, } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -32,6 +33,7 @@ import Configure from './configure' import { useDataSourceAuthUpdate } from './hooks' import Item from './item' import DataSourcePluginActions from './plugin-actions' +import VisibilityModal from './visibility-modal' const getPluginVersion = (uniqueIdentifier?: string) => { return uniqueIdentifier?.match(/:([^:@]+)@/)?.[1] @@ -84,6 +86,7 @@ const Card = ({ pendingOperationCredentialId, } = usePluginAuthAction(pluginPayload, handleAuthUpdate) const changeCredentialIdRef = useRef(undefined) + const [visibilityCredential, setVisibilityCredential] = useState(null) const { mutateAsync: getPluginOAuthUrl, } = useGetDataSourceOAuthUrl(pluginPayload.provider) @@ -125,6 +128,9 @@ const Card = ({ changeCredentialIdRef.current = credentialItem.id handleOAuth() } + + if (action === 'visibility') + setVisibilityCredential(credentialItem) }, [ openConfirm, handleEdit, @@ -243,6 +249,17 @@ const Card = ({ /> ) } + { + !!visibilityCredential && ( + setVisibilityCredential(null)} + onUpdate={handleAuthUpdate} + disabled={disabled || !canManageCredential} + /> + ) + } ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/item.tsx b/web/app/components/header/account-setting/data-source-page-new/item.tsx index fe46af7c6b8..fb435759d80 100644 --- a/web/app/components/header/account-setting/data-source-page-new/item.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/item.tsx @@ -9,6 +9,7 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { PermissionLevel } from '@/models/permission' import Operator from './operator' type ItemProps = { @@ -82,6 +83,21 @@ const Item = ({ ) } + { + // all_team_members is the default scope, so only restricted scopes get a badge. + credentialItem.visibility === PermissionLevel.onlyMe && ( +
+ {t('form.permissionsOnlyMe', { ns: 'datasetSettings' })} +
+ ) + } + { + credentialItem.visibility === PermissionLevel.partialMembers && ( +
+ {t('form.permissionsInvitedMembers', { ns: 'datasetSettings' })} +
+ ) + } ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/operator.tsx b/web/app/components/header/account-setting/data-source-page-new/operator.tsx index 1a1b243d8c2..bc92ebc04ba 100644 --- a/web/app/components/header/account-setting/data-source-page-new/operator.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/operator.tsx @@ -34,6 +34,8 @@ const Operator = ({ const { type, } = credentialItem + // Only the creator (or legacy NULL-owner rows) may change a credential's sharing scope. + const canEditVisibility = canManageCredential && credentialItem.is_editable !== false const handleAction = useCallback((action: string, allowed: boolean) => { if (!allowed) return @@ -83,6 +85,10 @@ const Operator = ({
{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}
)} + handleAction('visibility', canEditVisibility)}> + +
{t('auth.whoCanUse', { ns: 'plugin' })}
+
handleAction('delete', canManageCredential)}> diff --git a/web/app/components/header/account-setting/data-source-page-new/types.ts b/web/app/components/header/account-setting/data-source-page-new/types.ts index ce34498cf09..2fafe90c7b5 100644 --- a/web/app/components/header/account-setting/data-source-page-new/types.ts +++ b/web/app/components/header/account-setting/data-source-page-new/types.ts @@ -11,6 +11,14 @@ export type DataSourceCredential = { id: string is_default: boolean avatar_url: string + // Sharing scope: 'only_me' | 'all_team_members' | 'partial_members' + visibility?: string + // Creator account id (null for legacy credentials) + user_id?: string | null + // Whether the current user may change this credential's visibility + is_editable?: boolean + // Account ids granted access when visibility is 'partial_members' + partial_member_list?: string[] } export type DataSourceAuth = { author: string diff --git a/web/app/components/header/account-setting/data-source-page-new/visibility-modal.tsx b/web/app/components/header/account-setting/data-source-page-new/visibility-modal.tsx new file mode 100644 index 00000000000..6677c0cb9f2 --- /dev/null +++ b/web/app/components/header/account-setting/data-source-page-new/visibility-modal.tsx @@ -0,0 +1,118 @@ +import type { DataSourceCredential } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { + memo, + useCallback, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import PermissionSelector from '@/app/components/base/permission-selector' +import { PermissionLevel } from '@/models/permission' +import { useMembers } from '@/service/use-common' +import { useUpdateDataSourceCredentialVisibility } from '@/service/use-datasource' + +type VisibilityModalProps = { + // `${pluginId}/${name}`, matching the other datasource auth endpoints + provider: string + credentialItem: DataSourceCredential + onClose: () => void + onUpdate?: () => void + disabled?: boolean +} +const VisibilityModal = ({ + provider, + credentialItem, + onClose, + onUpdate, + disabled, +}: VisibilityModalProps) => { + const { t } = useTranslation() + const [doingAction, setDoingAction] = useState(false) + const [permission, setPermission] = useState( + (credentialItem.visibility as PermissionLevel) ?? PermissionLevel.allTeamMembers, + ) + const [selectedMemberIDs, setSelectedMemberIDs] = useState( + credentialItem.partial_member_list ?? [], + ) + const { data: membersData } = useMembers() + const memberList = membersData?.accounts ?? [] + const { mutateAsync: updateVisibility } = useUpdateDataSourceCredentialVisibility(provider) + + // partial_members requires at least one member; the backend rejects an empty list. + const isPartialMembersEmpty = permission === PermissionLevel.partialMembers && selectedMemberIDs.length === 0 + + const handleConfirm = useCallback(async () => { + if (doingAction || !permission || isPartialMembersEmpty) + return + try { + setDoingAction(true) + await updateVisibility({ + credential_id: credentialItem.id, + visibility: permission, + ...(permission === PermissionLevel.partialMembers + ? { partial_member_list: selectedMemberIDs } + : {}), + }) + toast.success(t('api.actionSuccess', { ns: 'common' })) + onUpdate?.() + onClose() + } + finally { + setDoingAction(false) + } + }, [doingAction, permission, isPartialMembersEmpty, selectedMemberIDs, updateVisibility, credentialItem.id, onUpdate, onClose, t]) + + const isDisabled = disabled || doingAction + const handleOpenChange = useCallback((nextOpen: boolean) => { + if (!nextOpen) + onClose() + }, [onClose]) + + return ( + + +
+
+ + {t('auth.whoCanUse', { ns: 'plugin' })} + + +
+
+ setPermission(v)} + onMemberSelect={setSelectedMemberIDs} + /> +
+
+ + +
+
+
+
+ ) +} + +export default memo(VisibilityModal) diff --git a/web/service/use-datasource.ts b/web/service/use-datasource.ts index 07fe6cd5634..56554001b32 100644 --- a/web/service/use-datasource.ts +++ b/web/service/use-datasource.ts @@ -6,7 +6,7 @@ import { useMutation, useQuery, } from '@tanstack/react-query' -import { get } from './base' +import { get, post } from './base' import { useInvalid } from './use-base' const NAME_SPACE = 'data-source-auth' @@ -78,3 +78,20 @@ export const useInvalidDataSourceAuth = ({ }) => { return useInvalid([NAME_SPACE, 'specific-data-source', pluginId, provider]) } + +// Update the sharing scope (visibility) of a datasource credential. +// `provider` is `${pluginId}/${name}`, matching the other datasource auth endpoints. +export const useUpdateDataSourceCredentialVisibility = ( + provider: string, +) => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-visibility', provider], + mutationFn: (params: { + credential_id: string + visibility: string + partial_member_list?: string[] + }) => { + return post(`/auth/plugin/datasource/${provider}/visibility`, { body: params }) + }, + }) +}