mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 14:51:13 +08:00
Merge 17f5452330 into bb921bcc45
This commit is contained in:
commit
3fd63ece3d
@ -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
|
||||
|
||||
@ -4645,6 +4645,25 @@ Refresh MCP server configuration and regenerate server code
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
|
||||
### [POST] /auth/plugin/datasource/{provider_id}/visibility
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| provider_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [DatasourceCredentialVisibilityPayload](#datasourcecredentialvisibilitypayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
|
||||
### [GET] /billing/invoices
|
||||
#### Responses
|
||||
|
||||
@ -15383,6 +15402,7 @@ Model class for provider custom model configuration.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| credentials | object | | Yes |
|
||||
| name | string | | No |
|
||||
| visibility | string | only_me or all_team_members (defaults to all_team) | No |
|
||||
|
||||
#### DatasourceCredentialUpdatePayload
|
||||
|
||||
@ -15392,6 +15412,14 @@ Model class for provider custom model configuration.
|
||||
| credentials | object | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### DatasourceCredentialVisibilityPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| credential_id | string | | Yes |
|
||||
| partial_member_list | [ string ] | account ids granted access when visibility is partial_members | No |
|
||||
| visibility | string | only_me, all_team_members, or partial_members | Yes |
|
||||
|
||||
#### DatasourceCredentialsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@ -28,6 +28,9 @@ import {
|
||||
zPostAuthPluginDatasourceByProviderIdUpdateNameResponse,
|
||||
zPostAuthPluginDatasourceByProviderIdUpdatePath,
|
||||
zPostAuthPluginDatasourceByProviderIdUpdateResponse,
|
||||
zPostAuthPluginDatasourceByProviderIdVisibilityBody,
|
||||
zPostAuthPluginDatasourceByProviderIdVisibilityPath,
|
||||
zPostAuthPluginDatasourceByProviderIdVisibilityResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const get = oc
|
||||
@ -171,6 +174,26 @@ export const updateName = {
|
||||
post: post5,
|
||||
}
|
||||
|
||||
export const post6 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAuthPluginDatasourceByProviderIdVisibility',
|
||||
path: '/auth/plugin/datasource/{provider_id}/visibility',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAuthPluginDatasourceByProviderIdVisibilityBody,
|
||||
params: zPostAuthPluginDatasourceByProviderIdVisibilityPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAuthPluginDatasourceByProviderIdVisibilityResponse)
|
||||
|
||||
export const visibility = {
|
||||
post: post6,
|
||||
}
|
||||
|
||||
export const get3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@ -182,7 +205,7 @@ export const get3 = oc
|
||||
.input(z.object({ params: zGetAuthPluginDatasourceByProviderIdPath }))
|
||||
.output(zGetAuthPluginDatasourceByProviderIdResponse)
|
||||
|
||||
export const post6 = oc
|
||||
export const post7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -200,12 +223,13 @@ export const post6 = oc
|
||||
|
||||
export const byProviderId = {
|
||||
get: get3,
|
||||
post: post6,
|
||||
post: post7,
|
||||
customClient,
|
||||
default: default_,
|
||||
delete: delete2,
|
||||
update,
|
||||
updateName,
|
||||
visibility,
|
||||
}
|
||||
|
||||
export const datasource = {
|
||||
|
||||
@ -13,6 +13,7 @@ export type DatasourceCredentialPayload = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
name?: string | null
|
||||
visibility?: string | null
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
@ -47,6 +48,12 @@ export type DatasourceUpdateNamePayload = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type DatasourceCredentialVisibilityPayload = {
|
||||
credential_id: string
|
||||
partial_member_list?: Array<string> | null
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export type GetAuthPluginDatasourceDefaultListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@ -202,3 +209,19 @@ export type PostAuthPluginDatasourceByProviderIdUpdateNameResponses = {
|
||||
|
||||
export type PostAuthPluginDatasourceByProviderIdUpdateNameResponse
|
||||
= PostAuthPluginDatasourceByProviderIdUpdateNameResponses[keyof PostAuthPluginDatasourceByProviderIdUpdateNameResponses]
|
||||
|
||||
export type PostAuthPluginDatasourceByProviderIdVisibilityData = {
|
||||
body: DatasourceCredentialVisibilityPayload
|
||||
path: {
|
||||
provider_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/auth/plugin/datasource/{provider_id}/visibility'
|
||||
}
|
||||
|
||||
export type PostAuthPluginDatasourceByProviderIdVisibilityResponses = {
|
||||
200: SimpleResultResponse
|
||||
}
|
||||
|
||||
export type PostAuthPluginDatasourceByProviderIdVisibilityResponse
|
||||
= PostAuthPluginDatasourceByProviderIdVisibilityResponses[keyof PostAuthPluginDatasourceByProviderIdVisibilityResponses]
|
||||
|
||||
@ -15,6 +15,7 @@ export const zDatasourceCredentialsResponse = z.object({
|
||||
export const zDatasourceCredentialPayload = z.object({
|
||||
credentials: z.record(z.string(), z.unknown()),
|
||||
name: z.string().max(100).nullish(),
|
||||
visibility: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -63,6 +64,15 @@ export const zDatasourceUpdateNamePayload = z.object({
|
||||
name: z.string().max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* DatasourceCredentialVisibilityPayload
|
||||
*/
|
||||
export const zDatasourceCredentialVisibilityPayload = z.object({
|
||||
credential_id: z.string(),
|
||||
partial_member_list: z.array(z.string()).nullish(),
|
||||
visibility: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
@ -156,3 +166,15 @@ export const zPostAuthPluginDatasourceByProviderIdUpdateNamePath = z.object({
|
||||
* Success
|
||||
*/
|
||||
export const zPostAuthPluginDatasourceByProviderIdUpdateNameResponse = zSimpleResultResponse
|
||||
|
||||
export const zPostAuthPluginDatasourceByProviderIdVisibilityBody
|
||||
= zDatasourceCredentialVisibilityPayload
|
||||
|
||||
export const zPostAuthPluginDatasourceByProviderIdVisibilityPath = z.object({
|
||||
provider_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPostAuthPluginDatasourceByProviderIdVisibilityResponse = zSimpleResultResponse
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
@ -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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user