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.
This commit is contained in:
kota-maeda0708 2026-06-21 18:21:57 +09:00
parent 4964359961
commit c232375fd2
14 changed files with 742 additions and 14 deletions

View File

@ -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/<path:provider_id>/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

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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)
# -----------------------------------------------------------------------

View File

@ -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(
<Item
credentialItem={{ ...mockCredentialItem, visibility: PermissionLevel.onlyMe }}
onAction={mockOnAction}
/>,
)
expect(screen.getByText('datasetSettings.form.permissionsOnlyMe')).toBeInTheDocument()
})
it('should show the "invited members" badge when visibility is partial_members', () => {
render(
<Item
credentialItem={{ ...mockCredentialItem, visibility: PermissionLevel.partialMembers }}
onAction={mockOnAction}
/>,
)
expect(screen.getByText('datasetSettings.form.permissionsInvitedMembers')).toBeInTheDocument()
})
it('should not show a scope badge for the default all_team_members visibility', () => {
render(
<Item
credentialItem={{ ...mockCredentialItem, visibility: PermissionLevel.allTeamMembers }}
onAction={mockOnAction}
/>,
)
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

View File

@ -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(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />)
// 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(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} canManageCredential />)
// 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(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
await userEvent.setup().click(screen.getByRole('button'))
fireEvent.click(await screen.findByText('plugin.auth.whoCanUse'))
// Assert
await waitFor(() => {
expect(mockOnAction).not.toHaveBeenCalled()
})
})
})
})

View File

@ -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> = {}): 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<DataSourceCredential> = {}) =>
render(
<VisibilityModal
provider="plugin-id/datasource"
credentialItem={createCredential(credentialOverrides)}
onClose={mockOnClose}
onUpdate={mockOnUpdate}
/>,
)
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()
})
})
})

View File

@ -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<string | undefined>(undefined)
const [visibilityCredential, setVisibilityCredential] = useState<DataSourceCredential | null>(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 && (
<VisibilityModal
provider={pluginPayload.provider}
credentialItem={visibilityCredential}
onClose={() => setVisibilityCredential(null)}
onUpdate={handleAuthUpdate}
disabled={disabled || !canManageCredential}
/>
)
}
</div>
)
}

View File

@ -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 = ({
</div>
)
}
{
// all_team_members is the default scope, so only restricted scopes get a badge.
credentialItem.visibility === PermissionLevel.onlyMe && (
<div className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium text-text-tertiary">
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
</div>
)
}
{
credentialItem.visibility === PermissionLevel.partialMembers && (
<div className="shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium text-text-tertiary">
{t('form.permissionsInvitedMembers', { ns: 'datasetSettings' })}
</div>
)
}
</>
)
}

View File

@ -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 = ({
<div className="mb-1 system-sm-semibold text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!canEditVisibility} className="h-auto gap-2 py-2" onClick={() => handleAction('visibility', canEditVisibility)}>
<span aria-hidden className="i-ri-group-line size-4 text-text-tertiary" />
<div className="system-sm-semibold text-text-secondary">{t('auth.whoCanUse', { ns: 'plugin' })}</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!canManageCredential} variant="destructive" className="h-auto gap-2 py-2" onClick={() => handleAction('delete', canManageCredential)}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />

View File

@ -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

View File

@ -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<PermissionLevel | undefined>(
(credentialItem.visibility as PermissionLevel) ?? PermissionLevel.allTeamMembers,
)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(
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 (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!"
>
<div className="flex flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('auth.whoCanUse', { ns: 'plugin' })}
</DialogTitle>
<DialogCloseButton className="top-5 right-5 size-8 rounded-lg" />
</div>
<div className="px-6 py-3">
<PermissionSelector
disabled={isDisabled}
permission={permission}
value={selectedMemberIDs}
memberList={memberList}
onChange={v => setPermission(v)}
onMemberSelect={setSelectedMemberIDs}
/>
</div>
<div className="flex shrink-0 justify-end p-6 pt-5">
<Button
onClick={() => handleOpenChange(false)}
disabled={isDisabled}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
className="ml-2"
variant="primary"
onClick={handleConfirm}
disabled={isDisabled || isPartialMembersEmpty}
>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default memo(VisibilityModal)

View File

@ -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 })
},
})
}