From 0f5d9ebd72b8b757caba5543eeb13bca62829823 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Thu, 20 Aug 2026 18:14:33 +0800 Subject: [PATCH 001/108] feat: add network access policy console API --- api/controllers/console/__init__.py | 2 + .../workspace/network_access_policy.py | 150 ++++++++++++++++++ api/services/billing_service.py | 100 ++++++++++++ .../workspace/test_network_access_policy.py | 148 +++++++++++++++++ ...t_network_access_policy_billing_service.py | 111 +++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 api/controllers/console/workspace/network_access_policy.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py create mode 100644 api/tests/unit_tests/services/test_network_access_policy_billing_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index afad88a8ce3c78..28db0336e13d55 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -155,6 +155,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError) members, model_providers, models, + network_access_policy, plugin, rbac, snippets, @@ -219,6 +220,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError) "model_config", "model_providers", "models", + "network_access_policy", "notification", "oauth", "oauth_server", diff --git a/api/controllers/console/workspace/network_access_policy.py b/api/controllers/console/workspace/network_access_policy.py new file mode 100644 index 00000000000000..946607d4f5a5c9 --- /dev/null +++ b/api/controllers/console/workspace/network_access_policy.py @@ -0,0 +1,150 @@ +from datetime import datetime +from typing import Literal + +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, Field, ValidationError +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.wraps import ( + account_initialization_required, + model_validate, + only_edition_cloud, + setup_required, + with_current_tenant_id, + with_current_user, +) +from extensions.ext_database import db +from fields.base import ResponseModel +from libs.helper import dump_response +from libs.login import login_required +from models import Account +from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError + +NetworkAccessPolicyScope = Literal["service_api", "mcp", "workflow_webhook", "plugin_trigger"] +NetworkAccessPolicyMode = Literal["disabled", "shadow", "enforce"] + + +class NetworkAccessPolicyPayload(BaseModel): + scope: NetworkAccessPolicyScope + mode: NetworkAccessPolicyMode + allowed_cidrs: list[str] = Field(max_length=100) + expected_version: int = Field(ge=0) + + +class NetworkAccessPolicyResponse(ResponseModel): + scope: NetworkAccessPolicyScope + mode: NetworkAccessPolicyMode + allowed_cidrs: list[str] = Field(validation_alias=AliasChoices("allowed_cidrs", "allowedCidrs")) + version: int = Field(ge=0) + updated_by_account_id: str | None = Field( + default=None, + validation_alias=AliasChoices("updated_by_account_id", "updatedByAccountId"), + ) + created_at: datetime | None = Field(default=None, validation_alias=AliasChoices("created_at", "createdAt")) + updated_at: datetime | None = Field(default=None, validation_alias=AliasChoices("updated_at", "updatedAt")) + + +class NetworkAccessPolicyListResponse(ResponseModel): + tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) + entitled: bool + policies: list[NetworkAccessPolicyResponse] + + +class NetworkAccessPolicyUpdateResponse(ResponseModel): + policy: NetworkAccessPolicyResponse + + +register_schema_models(console_ns, NetworkAccessPolicyPayload) +register_response_schema_models( + console_ns, + NetworkAccessPolicyResponse, + NetworkAccessPolicyListResponse, + NetworkAccessPolicyUpdateResponse, +) + + +def _ensure_workspace_admin_or_owner(current_user: Account) -> None: + """Authorize against persisted workspace membership, including when RBAC is enabled.""" + + try: + BillingService.is_tenant_owner_or_admin(current_user, session=db.session()) + except ValueError as exc: + raise Forbidden("Only workspace owners and administrators can manage network access policies.") from exc + + +def _translate_upstream_error(exc: NetworkAccessPolicyUpstreamError) -> Exception: + if exc.status_code in (400, 422): + return BadRequest("Invalid network access policy.") + if exc.status_code in (401, 403): + return Forbidden("Network access policy is not available for this workspace.") + if exc.status_code == 404: + return NotFound("Workspace network access policy was not found.") + if exc.status_code == 409: + return Conflict("The network access policy changed. Refresh it and try again.") + if exc.status_code >= 500: + return ServiceUnavailable("Network access policy service is unavailable.") + return BadGateway("Unexpected response from the network access policy service.") + + +def _serialize_response(response_model: type[ResponseModel], payload: dict): + try: + return dump_response(response_model, payload) + except ValidationError as exc: + raise BadGateway("Invalid response from the network access policy service.") from exc + + +@console_ns.route("/workspaces/current/network-access-policy") +class CurrentWorkspaceNetworkAccessPolicyApi(Resource): + @console_ns.response( + 200, + "Workspace network access policies retrieved successfully", + console_ns.models[NetworkAccessPolicyListResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.get_network_access_policies(current_tenant_id, current_user.id) + except NetworkAccessPolicyUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessPolicyListResponse, payload) + + @console_ns.expect(console_ns.models[NetworkAccessPolicyPayload.__name__]) + @console_ns.response( + 200, + "Workspace network access policy updated successfully", + console_ns.models[NetworkAccessPolicyUpdateResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @model_validate(NetworkAccessPolicyPayload) + def put( + self, + req_data: NetworkAccessPolicyPayload, + current_tenant_id: str, + current_user: Account, + ): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.update_network_access_policy( + current_tenant_id, + req_data.scope, + mode=req_data.mode, + allowed_cidrs=req_data.allowed_cidrs, + expected_version=req_data.expected_version, + actor_account_id=current_user.id, + ) + except NetworkAccessPolicyUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessPolicyUpdateResponse, payload) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 2ceaf144498872..caebe84b92461c 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -199,6 +199,14 @@ class DismissNotificationDict(TypedDict): success: bool +class NetworkAccessPolicyUpstreamError(Exception): + """Failure returned by the SaaS network-access-policy control plane.""" + + def __init__(self, status_code: int): + self.status_code = status_code + super().__init__(f"network access policy upstream returned HTTP {status_code}") + + class BillingService: base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL") quota_base_url = os.environ.get("BILLING_QUOTA_API_URL") or base_url @@ -418,6 +426,98 @@ def get_tenant_feature_plan_usage(cls, tenant_id: str, feature_key: str): params = {"tenant_id": tenant_id, "feature_key": feature_key} return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params) + @classmethod + def get_network_access_policies(cls, tenant_id: str, actor_account_id: str) -> dict[str, Any]: + """Fetch workspace network access policies from the SaaS control plane.""" + + return cls._send_network_access_policy_request( + "GET", + f"/tenants/{tenant_id}/network-access-policies", + params={"actor_account_id": actor_account_id}, + ) + + @classmethod + def update_network_access_policy( + cls, + tenant_id: str, + scope: str, + *, + mode: str, + allowed_cidrs: list[str], + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + """Replace one workspace policy; the authenticated account is injected by the Console BFF.""" + + return cls._send_network_access_policy_request( + "PUT", + f"/tenants/{tenant_id}/network-access-policies/{scope}", + payload_json={ + "mode": mode, + "allowed_cidrs": allowed_cidrs, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @classmethod + def _send_network_access_policy_request( + cls, + method: Literal["GET", "PUT"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Call the SaaS policy API while preserving its status for the Console boundary.""" + + try: + response = cls._send_network_access_policy_http_request( + method, + endpoint, + payload_json=payload_json, + params=params, + ) + except httpx.RequestError as exc: + raise NetworkAccessPolicyUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc + + if response.status_code != httpx.codes.OK: + raise NetworkAccessPolicyUpstreamError(response.status_code) + + try: + payload = response.json() + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise NetworkAccessPolicyUpstreamError(httpx.codes.BAD_GATEWAY) from exc + + if not isinstance(payload, dict): + raise NetworkAccessPolicyUpstreamError(httpx.codes.BAD_GATEWAY) + return payload + + @classmethod + @retry( + wait=wait_fixed(2), + stop=stop_before_delay(10), + retry=retry_if_exception_type(httpx.RequestError), + reraise=True, + ) + def _send_network_access_policy_http_request( + cls, + method: Literal["GET", "PUT"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} + return _http_client.request( + method, + f"{cls.base_url}{endpoint}", + json=payload_json, + params=params, + headers=headers, + follow_redirects=True, + ) + @classmethod def _send_quota_request( cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py new file mode 100644 index 00000000000000..401efbe7c23d86 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py @@ -0,0 +1,148 @@ +from datetime import UTC, datetime +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from pydantic import ValidationError +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable + +from controllers.console.workspace.network_access_policy import ( + CurrentWorkspaceNetworkAccessPolicyApi, + NetworkAccessPolicyPayload, + _translate_upstream_error, +) +from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError + + +def _policy_payload() -> dict: + return { + "scope": "service_api", + "mode": "enforce", + "allowedCidrs": ["203.0.113.7/32"], + "version": "2", + "updatedByAccountId": "account-1", + "createdAt": datetime(2026, 8, 20, tzinfo=UTC).isoformat(), + "updatedAt": datetime(2026, 8, 20, tzinfo=UTC).isoformat(), + } + + +def test_get_authorizes_and_forwards_tenant_and_actor(): + api = CurrentWorkspaceNetworkAccessPolicyApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id="account-1") + upstream_payload = {"tenantId": "tenant-1", "entitled": True, "policies": [_policy_payload()]} + + with ( + patch("controllers.console.workspace.network_access_policy.db") as db, + patch.object(BillingService, "is_tenant_owner_or_admin") as authorize, + patch.object(BillingService, "get_network_access_policies", return_value=upstream_payload) as get_policies, + ): + result = method(api, current_tenant_id="tenant-1", current_user=current_user) + + authorize.assert_called_once_with(current_user, session=db.session()) + get_policies.assert_called_once_with("tenant-1", "account-1") + assert result["tenant_id"] == "tenant-1" + assert result["policies"][0]["scope"] == "service_api" + assert result["policies"][0]["updated_at"] == "2026-08-20T00:00:00Z" + + +def test_put_injects_actor_and_does_not_accept_it_from_payload(): + api = CurrentWorkspaceNetworkAccessPolicyApi() + method = unwrap(api.put) + current_user = SimpleNamespace(id="account-1") + request_payload = NetworkAccessPolicyPayload( + scope="service_api", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + expected_version=1, + ) + + with ( + patch("controllers.console.workspace.network_access_policy.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "update_network_access_policy", + return_value={"policy": _policy_payload()}, + ) as update_policy, + ): + result = method( + api, + req_data=request_payload, + current_tenant_id="tenant-1", + current_user=current_user, + ) + + update_policy.assert_called_once_with( + "tenant-1", + "service_api", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + expected_version=1, + actor_account_id="account-1", + ) + assert result["policy"]["version"] == 2 + + +def test_get_rejects_non_privileged_workspace_member_before_upstream_call(): + api = CurrentWorkspaceNetworkAccessPolicyApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id="account-1") + + with ( + patch("controllers.console.workspace.network_access_policy.db"), + patch.object(BillingService, "is_tenant_owner_or_admin", side_effect=ValueError("not privileged")), + patch.object(BillingService, "get_network_access_policies") as get_policies, + pytest.raises(Forbidden), + ): + method(api, current_tenant_id="tenant-1", current_user=current_user) + + get_policies.assert_not_called() + + +@pytest.mark.parametrize( + ("status_code", "expected_exception"), + [ + (400, BadRequest), + (403, Forbidden), + (404, NotFound), + (409, Conflict), + (500, ServiceUnavailable), + (418, BadGateway), + ], +) +def test_upstream_error_mapping(status_code, expected_exception): + assert isinstance(_translate_upstream_error(NetworkAccessPolicyUpstreamError(status_code)), expected_exception) + + +def test_get_maps_invalid_upstream_contract_to_bad_gateway(): + api = CurrentWorkspaceNetworkAccessPolicyApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id="account-1") + + with ( + patch("controllers.console.workspace.network_access_policy.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "get_network_access_policies", + return_value={"tenant_id": "tenant-1", "entitled": True, "policies": [{"mode": "disabled"}]}, + ), + pytest.raises(BadGateway), + ): + method(api, current_tenant_id="tenant-1", current_user=current_user) + + +@pytest.mark.parametrize( + "payload", + [ + {"scope": "unknown", "mode": "disabled", "allowed_cidrs": [], "expected_version": 0}, + {"scope": "mcp", "mode": "unknown", "allowed_cidrs": [], "expected_version": 0}, + {"scope": "mcp", "mode": "disabled", "allowed_cidrs": [], "expected_version": -1}, + {"scope": "mcp", "mode": "disabled", "allowed_cidrs": ["127.0.0.1"] * 101, "expected_version": 0}, + ], +) +def test_payload_contract_rejects_invalid_shapes(payload): + with pytest.raises(ValidationError): + NetworkAccessPolicyPayload.model_validate(payload) diff --git a/api/tests/unit_tests/services/test_network_access_policy_billing_service.py b/api/tests/unit_tests/services/test_network_access_policy_billing_service.py new file mode 100644 index 00000000000000..61160406f1cc49 --- /dev/null +++ b/api/tests/unit_tests/services/test_network_access_policy_billing_service.py @@ -0,0 +1,111 @@ +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError + + +@pytest.fixture +def billing_config(): + with ( + patch.object(BillingService, "base_url", "https://billing.internal/v1"), + patch.object(BillingService, "secret_key", "test-secret"), + ): + yield + + +@pytest.mark.usefixtures("billing_config") +def test_get_network_access_policies_uses_authenticated_saas_endpoint(): + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = {"tenant_id": "tenant-1", "entitled": True, "policies": []} + + with patch("services.billing_service._http_client.request", return_value=response) as request: + result = BillingService.get_network_access_policies("tenant-1", "account-1") + + assert result == {"tenant_id": "tenant-1", "entitled": True, "policies": []} + request.assert_called_once_with( + "GET", + "https://billing.internal/v1/tenants/tenant-1/network-access-policies", + json=None, + params={"actor_account_id": "account-1"}, + headers={"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"}, + follow_redirects=True, + ) + + +@pytest.mark.usefixtures("billing_config") +def test_update_network_access_policy_injects_actor_and_scope(): + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = { + "policy": { + "scope": "service_api", + "mode": "enforce", + "allowed_cidrs": ["203.0.113.7/32"], + "version": 2, + } + } + + with patch("services.billing_service._http_client.request", return_value=response) as request: + result = BillingService.update_network_access_policy( + "tenant-1", + "service_api", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + expected_version=1, + actor_account_id="account-1", + ) + + assert result["policy"]["version"] == 2 + request.assert_called_once_with( + "PUT", + "https://billing.internal/v1/tenants/tenant-1/network-access-policies/service_api", + json={ + "mode": "enforce", + "allowed_cidrs": ["203.0.113.7/32"], + "expected_version": 1, + "actor_account_id": "account-1", + }, + params=None, + headers={"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"}, + follow_redirects=True, + ) + + +@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500]) +def test_network_access_policy_request_preserves_upstream_status(status_code): + response = MagicMock(status_code=status_code) + with ( + patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), + pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, + ): + BillingService._send_network_access_policy_request("GET", "/policies") + + assert exc_info.value.status_code == status_code + + +def test_network_access_policy_request_maps_transport_failure_to_service_unavailable(): + request = httpx.Request("GET", "https://billing.internal/v1/policies") + with ( + patch.object( + BillingService, + "_send_network_access_policy_http_request", + side_effect=httpx.ConnectError("unavailable", request=request), + ), + pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, + ): + BillingService._send_network_access_policy_request("GET", "/policies") + + assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE + + +def test_network_access_policy_request_rejects_non_object_response(): + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = [] + with ( + patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), + pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, + ): + BillingService._send_network_access_policy_request("GET", "/policies") + + assert exc_info.value.status_code == httpx.codes.BAD_GATEWAY From 4dc94b7a0bad0052f5b217bbbcfd53fbd24efc6b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:35:52 +0000 Subject: [PATCH 002/108] [autofix.ci] apply automated fixes --- api/openapi/markdown/console-openapi.md | 55 +++ .../api/console/workspaces/orpc.gen.ts | 382 ++++++++++-------- .../api/console/workspaces/types.gen.ts | 55 +++ .../api/console/workspaces/zod.gen.ts | 51 +++ 4 files changed, 367 insertions(+), 176 deletions(-) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 6e97fe124e3c7d..b474afac4632dc 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -10698,6 +10698,26 @@ Update a plugin endpoint | ---- | ----------- | ------ | | 200 | Available models retrieved successfully | **application/json**: [AvailableModelListResponse](#availablemodellistresponse)
| +### [GET] /workspaces/current/network-access-policy +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access policies retrieved successfully | **application/json**: [NetworkAccessPolicyListResponse](#networkaccesspolicylistresponse)
| + +### [PUT] /workspaces/current/network-access-policy +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NetworkAccessPolicyPayload](#networkaccesspolicypayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access policy updated successfully | **application/json**: [NetworkAccessPolicyUpdateResponse](#networkaccesspolicyupdateresponse)
| + ### [GET] /workspaces/current/permission **Get workspace permission settings** @@ -19087,6 +19107,41 @@ Model with provider entity. | dataset | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | | workspace | [WorkspacePermissionSnapshot](#workspacepermissionsnapshot) | | No | +#### NetworkAccessPolicyListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entitled | boolean | | Yes | +| policies | [ [NetworkAccessPolicyResponse](#networkaccesspolicyresponse) ] | | Yes | +| tenant_id | string | | Yes | + +#### NetworkAccessPolicyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| expected_version | integer | | Yes | +| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | +| scope | string,
**Available values:** "mcp", "plugin_trigger", "service_api", "workflow_webhook" | *Enum:* `"mcp"`, `"plugin_trigger"`, `"service_api"`, `"workflow_webhook"` | Yes | + +#### NetworkAccessPolicyResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| created_at | string | | No | +| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | +| scope | string,
**Available values:** "mcp", "plugin_trigger", "service_api", "workflow_webhook" | *Enum:* `"mcp"`, `"plugin_trigger"`, `"service_api"`, `"workflow_webhook"` | Yes | +| updated_at | string | | No | +| updated_by_account_id | string | | No | +| version | integer | | Yes | + +#### NetworkAccessPolicyUpdateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| policy | [NetworkAccessPolicyResponse](#networkaccesspolicyresponse) | | Yes | + #### NewAppResponse | Name | Type | Description | Required | diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 292ad4b0ebe5b4..499b3ef6e05ef0 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -75,6 +75,7 @@ import { zGetWorkspacesCurrentModelProvidersSummaryResponse, zGetWorkspacesCurrentModelsModelTypesByModelTypePath, zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse, + zGetWorkspacesCurrentNetworkAccessPolicyResponse, zGetWorkspacesCurrentPermissionResponse, zGetWorkspacesCurrentPluginAssetQuery, zGetWorkspacesCurrentPluginAssetResponse, @@ -396,6 +397,8 @@ import { zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, + zPutWorkspacesCurrentNetworkAccessPolicyBody, + zPutWorkspacesCurrentNetworkAccessPolicyResponse, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath, @@ -1583,12 +1586,38 @@ export const models2 = { modelTypes, } +export const get21 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessPolicy', + path: '/workspaces/current/network-access-policy', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentNetworkAccessPolicyResponse) + +export const put4 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentNetworkAccessPolicy', + path: '/workspaces/current/network-access-policy', + tags: ['console'], + }) + .input(z.object({ body: zPutWorkspacesCurrentNetworkAccessPolicyBody })) + .output(zPutWorkspacesCurrentNetworkAccessPolicyResponse) + +export const networkAccessPolicy = { + get: get21, + put: put4, +} + /** * Get workspace permission settings * * Returns permission flags that control workspace features like member invitations and owner transfer. */ -export const get21 = oc +export const get22 = oc .route({ description: 'Returns permission flags that control workspace features like member invitations and owner transfer.', @@ -1602,10 +1631,10 @@ export const get21 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get21, + get: get22, } -export const get22 = oc +export const get23 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1617,7 +1646,7 @@ export const get22 = oc .output(zGetWorkspacesCurrentPluginAssetResponse) export const asset = { - get: get22, + get: get23, } export const post26 = oc @@ -1650,7 +1679,7 @@ export const exclude = { post: post27, } -export const get23 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1662,7 +1691,7 @@ export const get23 = oc .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) export const fetch_ = { - get: get23, + get: get24, } export const autoUpgrade = { @@ -1671,7 +1700,7 @@ export const autoUpgrade = { fetch: fetch_, } -export const get24 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1682,10 +1711,10 @@ export const get24 = oc .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) export const debuggingKey = { - get: get24, + get: get25, } -export const get25 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1697,10 +1726,10 @@ export const get25 = oc .output(zGetWorkspacesCurrentPluginFetchManifestResponse) export const fetchManifest = { - get: get25, + get: get26, } -export const get26 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1712,7 +1741,7 @@ export const get26 = oc .output(zGetWorkspacesCurrentPluginIconResponse) export const icon = { - get: get26, + get: get27, } export const post28 = oc @@ -1766,7 +1795,7 @@ export const install = { pkg, } -export const get27 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1778,7 +1807,7 @@ export const get27 = oc .output(zGetWorkspacesCurrentPluginInstalledIdsResponse) export const installedIds = { - get: get27, + get: get28, } export const post31 = oc @@ -1815,7 +1844,7 @@ export const latestVersions = { post: post32, } -export const get28 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1827,12 +1856,12 @@ export const get28 = oc .output(zGetWorkspacesCurrentPluginListResponse) export const list2 = { - get: get28, + get: get29, installations, latestVersions, } -export const get29 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1844,14 +1873,14 @@ export const get29 = oc .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) export const pkg2 = { - get: get29, + get: get30, } export const marketplace2 = { pkg: pkg2, } -export const get30 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1863,7 +1892,7 @@ export const get30 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get30, + get: get31, } /** @@ -1907,7 +1936,7 @@ export const change2 = { post: post34, } -export const get31 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1918,7 +1947,7 @@ export const get31 = oc .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) export const fetch2 = { - get: get31, + get: get32, } export const permission2 = { @@ -1926,7 +1955,7 @@ export const permission2 = { fetch: fetch2, } -export const get32 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1938,7 +1967,7 @@ export const get32 = oc .output(zGetWorkspacesCurrentPluginReadmeResponse) export const readme = { - get: get32, + get: get33, } export const post35 = oc @@ -1986,7 +2015,7 @@ export const delete8 = { byIdentifier, } -export const get33 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1998,11 +2027,11 @@ export const get33 = oc .output(zGetWorkspacesCurrentPluginTasksByTaskIdResponse) export const byTaskId = { - get: get33, + get: get34, delete: delete8, } -export const get34 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2014,7 +2043,7 @@ export const get34 = oc .output(zGetWorkspacesCurrentPluginTasksResponse) export const tasks = { - get: get34, + get: get35, deleteAll, byTaskId, } @@ -2119,7 +2148,7 @@ export const upload = { pkg: pkg3, } -export const get35 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2136,7 +2165,7 @@ export const get35 = oc .output(zGetWorkspacesCurrentPluginByCategoryListResponse) export const list3 = { - get: get35, + get: get36, } export const byCategory = { @@ -2190,7 +2219,7 @@ export const delete9 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const get36 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2201,7 +2230,7 @@ export const get36 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const put4 = oc +export const put5 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2214,12 +2243,12 @@ export const put4 = oc export const byPolicyId = { delete: delete9, - get: get36, - put: put4, + get: get37, + put: put5, copy, } -export const get37 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2241,12 +2270,12 @@ export const post45 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) export const accessPolicies = { - get: get37, + get: get38, post: post45, byPolicyId, } -export const put5 = oc +export const put6 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2258,10 +2287,10 @@ export const put5 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse) export const lock = { - put: put5, + put: put6, } -export const put6 = oc +export const put7 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2273,7 +2302,7 @@ export const put6 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse) export const unlock = { - put: put6, + put: put7, } export const byBindingId = { @@ -2301,7 +2330,7 @@ export const delete10 = oc ) .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get38 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2318,10 +2347,10 @@ export const get38 = oc export const memberBindings = { delete: delete10, - get: get38, + get: get39, } -export const get39 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2337,7 +2366,7 @@ export const get39 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings = { - get: get39, + get: get40, } export const byPolicyId2 = { @@ -2349,7 +2378,7 @@ export const accessPolicies2 = { byPolicyId: byPolicyId2, } -export const get40 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2366,10 +2395,10 @@ export const get40 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) export const accessPolicy = { - get: get40, + get: get41, } -export const get41 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2386,10 +2415,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) export const userAccessPolicies = { - get: get41, + get: get42, } -export const put7 = oc +export const put8 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2406,7 +2435,7 @@ export const put7 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies3 = { - put: put7, + put: put8, } export const byTargetAccountId = { @@ -2417,7 +2446,7 @@ export const users = { byTargetAccountId, } -export const get42 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2428,7 +2457,7 @@ export const get42 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) -export const put8 = oc +export const put9 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2445,8 +2474,8 @@ export const put8 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) export const whitelist = { - get: get42, - put: put8, + get: get43, + put: put9, } export const byAppId = { @@ -2481,7 +2510,7 @@ export const delete11 = oc zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, ) -export const get43 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2502,10 +2531,10 @@ export const get43 = oc export const memberBindings2 = { delete: delete11, - get: get43, + get: get44, } -export const get44 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2521,7 +2550,7 @@ export const get44 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings2 = { - get: get44, + get: get45, } export const byPolicyId3 = { @@ -2533,7 +2562,7 @@ export const accessPolicies4 = { byPolicyId: byPolicyId3, } -export const get45 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2550,10 +2579,10 @@ export const get45 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) export const accessPolicy2 = { - get: get45, + get: get46, } -export const get46 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2570,10 +2599,10 @@ export const get46 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) export const userAccessPolicies2 = { - get: get46, + get: get47, } -export const put9 = oc +export const put10 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2590,7 +2619,7 @@ export const put9 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies5 = { - put: put9, + put: put10, } export const byTargetAccountId2 = { @@ -2601,7 +2630,7 @@ export const users2 = { byTargetAccountId: byTargetAccountId2, } -export const get47 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2612,7 +2641,7 @@ export const get47 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) -export const put10 = oc +export const put11 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2629,8 +2658,8 @@ export const put10 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) export const whitelist2 = { - get: get47, - put: put10, + get: get48, + put: put11, } export const byDatasetId = { @@ -2645,7 +2674,7 @@ export const datasets = { byDatasetId, } -export const get48 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2656,7 +2685,7 @@ export const get48 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) -export const put11 = oc +export const put12 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2673,8 +2702,8 @@ export const put11 = oc .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const rbacRoles = { - get: get48, - put: put11, + get: get49, + put: put12, } export const byMemberId2 = { @@ -2685,7 +2714,7 @@ export const members2 = { byMemberId: byMemberId2, } -export const get49 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2696,10 +2725,10 @@ export const get49 = oc .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) export const myPermissions = { - get: get49, + get: get50, } -export const get50 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2710,10 +2739,10 @@ export const get50 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) export const app = { - get: get50, + get: get51, } -export const get51 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2724,10 +2753,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) export const dataset = { - get: get51, + get: get52, } -export const get52 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2738,7 +2767,7 @@ export const get52 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) export const catalog = { - get: get52, + get: get53, app, dataset, } @@ -2763,7 +2792,7 @@ export const copy2 = { post: post46, } -export const get53 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2775,7 +2804,7 @@ export const get53 = oc .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { - get: get53, + get: get54, } export const delete12 = oc @@ -2789,7 +2818,7 @@ export const delete12 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) -export const get54 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2800,7 +2829,7 @@ export const get54 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) -export const put12 = oc +export const put13 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2813,13 +2842,13 @@ export const put12 = oc export const byRoleId = { delete: delete12, - get: get54, - put: put12, + get: get55, + put: put13, copy: copy2, members: members3, } -export const get55 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2841,12 +2870,12 @@ export const post47 = oc .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { - get: get55, + get: get56, post: post47, byRoleId, } -export const put13 = oc +export const put14 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2863,10 +2892,10 @@ export const put13 = oc .output(zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse) export const bindings = { - put: put13, + put: put14, } -export const get56 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2882,10 +2911,10 @@ export const get56 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings3 = { - get: get56, + get: get57, } -export const get57 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2901,7 +2930,7 @@ export const get57 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings3 = { - get: get57, + get: get58, } export const byPolicyId4 = { @@ -2914,7 +2943,7 @@ export const accessPolicies6 = { byPolicyId: byPolicyId4, } -export const get58 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2925,7 +2954,7 @@ export const get58 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) export const accessPolicy3 = { - get: get58, + get: get59, } export const apps2 = { @@ -2933,7 +2962,7 @@ export const apps2 = { accessPolicy: accessPolicy3, } -export const put14 = oc +export const put15 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2950,10 +2979,10 @@ export const put14 = oc .output(zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse) export const bindings2 = { - put: put14, + put: put15, } -export const get59 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2969,10 +2998,10 @@ export const get59 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings4 = { - get: get59, + get: get60, } -export const get60 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2988,7 +3017,7 @@ export const get60 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings4 = { - get: get60, + get: get61, } export const byPolicyId5 = { @@ -3001,7 +3030,7 @@ export const accessPolicies7 = { byPolicyId: byPolicyId5, } -export const get61 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3012,7 +3041,7 @@ export const get61 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) export const accessPolicy4 = { - get: get61, + get: get62, } export const datasets2 = { @@ -3037,7 +3066,7 @@ export const rbac = { workspace, } -export const get62 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3048,10 +3077,10 @@ export const get62 = oc .output(zGetWorkspacesCurrentSummaryResponse) export const summary2 = { - get: get62, + get: get63, } -export const get63 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3062,7 +3091,7 @@ export const get63 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get63, + get: get64, } export const post48 = oc @@ -3095,7 +3124,7 @@ export const delete13 = { post: post49, } -export const get64 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3106,11 +3135,11 @@ export const get64 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get65 = { - get: get64, +export const get66 = { + get: get65, } -export const get66 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3122,7 +3151,7 @@ export const get66 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get66, + get: get67, } export const post50 = oc @@ -3159,7 +3188,7 @@ export const test = { pre, } -export const get67 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3171,7 +3200,7 @@ export const get67 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get67, + get: get68, } export const post52 = oc @@ -3192,7 +3221,7 @@ export const update2 = { export const api = { add, delete: delete13, - get: get65, + get: get66, remote, schema, test, @@ -3220,7 +3249,7 @@ export const add2 = { post: post53, } -export const get68 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3237,10 +3266,10 @@ export const get68 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get68, + get: get69, } -export const get69 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3260,7 +3289,7 @@ export const get69 = oc ) export const byCredentialType = { - get: get69, + get: get70, } export const schema2 = { @@ -3272,7 +3301,7 @@ export const credential = { schema: schema2, } -export const get70 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3289,7 +3318,7 @@ export const get70 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get70, + get: get71, } export const post54 = oc @@ -3332,7 +3361,7 @@ export const delete14 = { post: post55, } -export const get71 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3344,10 +3373,10 @@ export const get71 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get71, + get: get72, } -export const get72 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3359,10 +3388,10 @@ export const get72 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get72, + get: get73, } -export const get73 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3376,7 +3405,7 @@ export const get73 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get73, + get: get74, } export const delete15 = oc @@ -3394,7 +3423,7 @@ export const delete15 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get74 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3425,7 +3454,7 @@ export const post56 = oc export const customClient = { delete: delete15, - get: get74, + get: get75, post: post56, } @@ -3434,7 +3463,7 @@ export const oauth = { customClient, } -export const get75 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3446,7 +3475,7 @@ export const get75 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get75, + get: get76, } export const post57 = oc @@ -3501,7 +3530,7 @@ export const auth = { post: post58, } -export const get76 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3513,14 +3542,14 @@ export const get76 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get76, + get: get77, } export const tools3 = { byProviderId, } -export const get77 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3532,7 +3561,7 @@ export const get77 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get77, + get: get78, } export const update4 = { @@ -3561,7 +3590,7 @@ export const post59 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -export const put15 = oc +export const put16 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3575,7 +3604,7 @@ export const put15 = oc export const mcp = { delete: delete16, post: post59, - put: put15, + put: put16, auth, tools: tools3, update: update4, @@ -3611,7 +3640,7 @@ export const delete17 = { post: post61, } -export const get78 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3622,11 +3651,11 @@ export const get78 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get79 = { - get: get78, +export const get80 = { + get: get79, } -export const get80 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3638,7 +3667,7 @@ export const get80 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get80, + get: get81, } export const post62 = oc @@ -3659,7 +3688,7 @@ export const update5 = { export const workflow = { create: create2, delete: delete17, - get: get79, + get: get80, tools: tools4, update: update5, } @@ -3671,7 +3700,7 @@ export const toolProvider = { workflow, } -export const get81 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3683,10 +3712,10 @@ export const get81 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get81, + get: get82, } -export const get82 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3697,10 +3726,10 @@ export const get82 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get82, + get: get83, } -export const get83 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3711,10 +3740,10 @@ export const get83 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get83, + get: get84, } -export const get84 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3725,10 +3754,10 @@ export const get84 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get84, + get: get85, } -export const get85 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3739,7 +3768,7 @@ export const get85 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get85, + get: get86, } export const tools5 = { @@ -3749,7 +3778,7 @@ export const tools5 = { workflow: workflow2, } -export const get86 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3761,13 +3790,13 @@ export const get86 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get86, + get: get87, } /** * Get info for a trigger provider */ -export const get87 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3780,7 +3809,7 @@ export const get87 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get87, + get: get88, } /** @@ -3801,7 +3830,7 @@ export const delete18 = oc /** * Get OAuth client configuration for a provider */ -export const get88 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3835,7 +3864,7 @@ export const post63 = oc export const client = { delete: delete18, - get: get88, + get: get89, post: post63, } @@ -3902,7 +3931,7 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider */ -export const get89 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3923,7 +3952,7 @@ export const get89 = oc ) export const bySubscriptionBuilderId2 = { - get: get89, + get: get90, } export const logs = { @@ -3997,7 +4026,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get90 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4018,7 +4047,7 @@ export const get90 = oc ) export const bySubscriptionBuilderId5 = { - get: get90, + get: get91, } export const builder = { @@ -4033,7 +4062,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get91 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4046,13 +4075,13 @@ export const get91 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get91, + get: get92, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get92 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4069,7 +4098,7 @@ export const get92 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get92, + get: get93, } export const oauth3 = { @@ -4186,7 +4215,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get93 = oc +export const get94 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4198,7 +4227,7 @@ export const get93 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get93, + get: get94, } export const current = { @@ -4211,6 +4240,7 @@ export const current = { members, modelProviders, models: models2, + networkAccessPolicy, permission, plugin: plugin2, rbac, @@ -4243,7 +4273,7 @@ export const webappLogo = { upload: upload2, } -export const get94 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4265,7 +4295,7 @@ export const post72 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - get: get94, + get: get95, post: post72, webappLogo, } @@ -4300,7 +4330,7 @@ export const switch3 = { post: post74, } -export const get95 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4312,7 +4342,7 @@ export const get95 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get95, + get: get96, } export const byIconType = { @@ -4331,7 +4361,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get96 = oc +export const get97 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4342,7 +4372,7 @@ export const get96 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get96, + get: get97, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 99dab0f62917ed..417061a7caffb8 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -364,6 +364,23 @@ export type AvailableModelListResponse = { data: Array } +export type NetworkAccessPolicyListResponse = { + entitled: boolean + policies: Array + tenant_id: string +} + +export type NetworkAccessPolicyPayload = { + allowed_cidrs: Array + expected_version: number + mode: 'disabled' | 'enforce' | 'shadow' + scope: 'mcp' | 'plugin_trigger' | 'service_api' | 'workflow_webhook' +} + +export type NetworkAccessPolicyUpdateResponse = { + policy: NetworkAccessPolicyResponse +} + export type WorkspacePermissionResponse = { allow_member_invite: boolean allow_owner_transfer: boolean @@ -1289,6 +1306,16 @@ export type ProviderWithModelsResponse = { tenant_id: string } +export type NetworkAccessPolicyResponse = { + allowed_cidrs: Array + created_at?: string | null + mode: 'disabled' | 'enforce' | 'shadow' + scope: 'mcp' | 'plugin_trigger' | 'service_api' | 'workflow_webhook' + updated_at?: string | null + updated_by_account_id?: string | null + version: number +} + export type PluginAutoUpgradeSettingsPayload = { exclude_plugins?: Array include_plugins?: Array @@ -3499,6 +3526,34 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse = GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] +export type GetWorkspacesCurrentNetworkAccessPolicyData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/network-access-policy' +} + +export type GetWorkspacesCurrentNetworkAccessPolicyResponses = { + 200: NetworkAccessPolicyListResponse +} + +export type GetWorkspacesCurrentNetworkAccessPolicyResponse = + GetWorkspacesCurrentNetworkAccessPolicyResponses[keyof GetWorkspacesCurrentNetworkAccessPolicyResponses] + +export type PutWorkspacesCurrentNetworkAccessPolicyData = { + body: NetworkAccessPolicyPayload + path?: never + query?: never + url: '/workspaces/current/network-access-policy' +} + +export type PutWorkspacesCurrentNetworkAccessPolicyResponses = { + 200: NetworkAccessPolicyUpdateResponse +} + +export type PutWorkspacesCurrentNetworkAccessPolicyResponse = + PutWorkspacesCurrentNetworkAccessPolicyResponses[keyof PutWorkspacesCurrentNetworkAccessPolicyResponses] + export type GetWorkspacesCurrentPermissionData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index c55c0e70099953..7f4c1e7f230364 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -247,6 +247,16 @@ export const zParserPreferredProviderType = z.object({ preferred_provider_type: z.enum(['custom', 'system']), }) +/** + * NetworkAccessPolicyPayload + */ +export const zNetworkAccessPolicyPayload = z.object({ + allowed_cidrs: z.array(z.string()).max(100), + expected_version: z.int().gte(0), + mode: z.enum(['disabled', 'enforce', 'shadow']), + scope: z.enum(['mcp', 'plugin_trigger', 'service_api', 'workflow_webhook']), +}) + /** * WorkspacePermissionResponse */ @@ -985,6 +995,35 @@ export const zCredentialConfiguration = z.object({ credential_name: z.string(), }) +/** + * NetworkAccessPolicyResponse + */ +export const zNetworkAccessPolicyResponse = z.object({ + allowed_cidrs: z.array(z.string()), + created_at: z.iso.datetime().nullish(), + mode: z.enum(['disabled', 'enforce', 'shadow']), + scope: z.enum(['mcp', 'plugin_trigger', 'service_api', 'workflow_webhook']), + updated_at: z.iso.datetime().nullish(), + updated_by_account_id: z.string().nullish(), + version: z.int().gte(0), +}) + +/** + * NetworkAccessPolicyListResponse + */ +export const zNetworkAccessPolicyListResponse = z.object({ + entitled: z.boolean(), + policies: z.array(zNetworkAccessPolicyResponse), + tenant_id: z.string(), +}) + +/** + * NetworkAccessPolicyUpdateResponse + */ +export const zNetworkAccessPolicyUpdateResponse = z.object({ + policy: zNetworkAccessPolicyResponse, +}) + /** * TenantPluginAutoUpgradeCategory */ @@ -4101,6 +4140,18 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ */ export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = zAvailableModelListResponse +/** + * Workspace network access policies retrieved successfully + */ +export const zGetWorkspacesCurrentNetworkAccessPolicyResponse = zNetworkAccessPolicyListResponse + +export const zPutWorkspacesCurrentNetworkAccessPolicyBody = zNetworkAccessPolicyPayload + +/** + * Workspace network access policy updated successfully + */ +export const zPutWorkspacesCurrentNetworkAccessPolicyResponse = zNetworkAccessPolicyUpdateResponse + /** * Success */ From 642c11061a39ed2d25eaa03121c8a175af4a7bff Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Thu, 20 Aug 2026 18:38:07 +0800 Subject: [PATCH 003/108] test: satisfy strict API type checks --- .../workspace/test_network_access_policy.py | 16 +++++++-------- ...t_network_access_policy_billing_service.py | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py index 401efbe7c23d86..e1a384bf0f1c26 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py @@ -5,7 +5,7 @@ import pytest from pydantic import ValidationError -from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable from controllers.console.workspace.network_access_policy import ( CurrentWorkspaceNetworkAccessPolicyApi, @@ -15,7 +15,7 @@ from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError -def _policy_payload() -> dict: +def _policy_payload() -> dict[str, object]: return { "scope": "service_api", "mode": "enforce", @@ -27,7 +27,7 @@ def _policy_payload() -> dict: } -def test_get_authorizes_and_forwards_tenant_and_actor(): +def test_get_authorizes_and_forwards_tenant_and_actor() -> None: api = CurrentWorkspaceNetworkAccessPolicyApi() method = unwrap(api.get) current_user = SimpleNamespace(id="account-1") @@ -47,7 +47,7 @@ def test_get_authorizes_and_forwards_tenant_and_actor(): assert result["policies"][0]["updated_at"] == "2026-08-20T00:00:00Z" -def test_put_injects_actor_and_does_not_accept_it_from_payload(): +def test_put_injects_actor_and_does_not_accept_it_from_payload() -> None: api = CurrentWorkspaceNetworkAccessPolicyApi() method = unwrap(api.put) current_user = SimpleNamespace(id="account-1") @@ -85,7 +85,7 @@ def test_put_injects_actor_and_does_not_accept_it_from_payload(): assert result["policy"]["version"] == 2 -def test_get_rejects_non_privileged_workspace_member_before_upstream_call(): +def test_get_rejects_non_privileged_workspace_member_before_upstream_call() -> None: api = CurrentWorkspaceNetworkAccessPolicyApi() method = unwrap(api.get) current_user = SimpleNamespace(id="account-1") @@ -112,11 +112,11 @@ def test_get_rejects_non_privileged_workspace_member_before_upstream_call(): (418, BadGateway), ], ) -def test_upstream_error_mapping(status_code, expected_exception): +def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPException]) -> None: assert isinstance(_translate_upstream_error(NetworkAccessPolicyUpstreamError(status_code)), expected_exception) -def test_get_maps_invalid_upstream_contract_to_bad_gateway(): +def test_get_maps_invalid_upstream_contract_to_bad_gateway() -> None: api = CurrentWorkspaceNetworkAccessPolicyApi() method = unwrap(api.get) current_user = SimpleNamespace(id="account-1") @@ -143,6 +143,6 @@ def test_get_maps_invalid_upstream_contract_to_bad_gateway(): {"scope": "mcp", "mode": "disabled", "allowed_cidrs": ["127.0.0.1"] * 101, "expected_version": 0}, ], ) -def test_payload_contract_rejects_invalid_shapes(payload): +def test_payload_contract_rejects_invalid_shapes(payload: dict[str, object]) -> None: with pytest.raises(ValidationError): NetworkAccessPolicyPayload.model_validate(payload) diff --git a/api/tests/unit_tests/services/test_network_access_policy_billing_service.py b/api/tests/unit_tests/services/test_network_access_policy_billing_service.py index 61160406f1cc49..550181795ea3dc 100644 --- a/api/tests/unit_tests/services/test_network_access_policy_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_policy_billing_service.py @@ -1,3 +1,4 @@ +from collections.abc import Iterator from unittest.mock import MagicMock, patch import httpx @@ -7,7 +8,7 @@ @pytest.fixture -def billing_config(): +def billing_config() -> Iterator[None]: with ( patch.object(BillingService, "base_url", "https://billing.internal/v1"), patch.object(BillingService, "secret_key", "test-secret"), @@ -16,14 +17,15 @@ def billing_config(): @pytest.mark.usefixtures("billing_config") -def test_get_network_access_policies_uses_authenticated_saas_endpoint(): +def test_get_network_access_policies_uses_authenticated_saas_endpoint() -> None: response = MagicMock(status_code=httpx.codes.OK) - response.json.return_value = {"tenant_id": "tenant-1", "entitled": True, "policies": []} + policies: list[dict[str, object]] = [] + response.json.return_value = {"tenant_id": "tenant-1", "entitled": True, "policies": policies} with patch("services.billing_service._http_client.request", return_value=response) as request: result = BillingService.get_network_access_policies("tenant-1", "account-1") - assert result == {"tenant_id": "tenant-1", "entitled": True, "policies": []} + assert result == {"tenant_id": "tenant-1", "entitled": True, "policies": policies} request.assert_called_once_with( "GET", "https://billing.internal/v1/tenants/tenant-1/network-access-policies", @@ -35,7 +37,7 @@ def test_get_network_access_policies_uses_authenticated_saas_endpoint(): @pytest.mark.usefixtures("billing_config") -def test_update_network_access_policy_injects_actor_and_scope(): +def test_update_network_access_policy_injects_actor_and_scope() -> None: response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = { "policy": { @@ -73,7 +75,7 @@ def test_update_network_access_policy_injects_actor_and_scope(): @pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500]) -def test_network_access_policy_request_preserves_upstream_status(status_code): +def test_network_access_policy_request_preserves_upstream_status(status_code: int) -> None: response = MagicMock(status_code=status_code) with ( patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), @@ -84,7 +86,7 @@ def test_network_access_policy_request_preserves_upstream_status(status_code): assert exc_info.value.status_code == status_code -def test_network_access_policy_request_maps_transport_failure_to_service_unavailable(): +def test_network_access_policy_request_maps_transport_failure_to_service_unavailable() -> None: request = httpx.Request("GET", "https://billing.internal/v1/policies") with ( patch.object( @@ -99,9 +101,9 @@ def test_network_access_policy_request_maps_transport_failure_to_service_unavail assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE -def test_network_access_policy_request_rejects_non_object_response(): +def test_network_access_policy_request_rejects_non_object_response() -> None: response = MagicMock(status_code=httpx.codes.OK) - response.json.return_value = [] + response.json.return_value = list[object]() with ( patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, From 2c254a4a7090e75e82be05576b468d66fc0a427f Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 21 Aug 2026 11:29:28 +0800 Subject: [PATCH 004/108] feat: manage reusable network access groups --- api/controllers/console/__init__.py | 4 +- .../console/workspace/network_access_group.py | 369 ++++++++++ .../workspace/network_access_policy.py | 150 ----- api/openapi/markdown/console-openapi.md | 170 ++++- api/services/billing_service.py | 157 ++++- .../workspace/test_network_access_group.py | 392 +++++++++++ .../workspace/test_network_access_policy.py | 148 ---- ...st_network_access_group_billing_service.py | 239 +++++++ ...t_network_access_policy_billing_service.py | 113 ---- .../generated/api/console/apps/orpc.gen.ts | 282 ++++---- .../generated/api/console/apps/types.gen.ts | 59 ++ .../generated/api/console/apps/zod.gen.ts | 59 ++ .../api/console/workspaces/orpc.gen.ts | 631 ++++++++++-------- .../api/console/workspaces/types.gen.ts | 115 +++- .../api/console/workspaces/zod.gen.ts | 102 ++- 15 files changed, 2079 insertions(+), 911 deletions(-) create mode 100644 api/controllers/console/workspace/network_access_group.py delete mode 100644 api/controllers/console/workspace/network_access_policy.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py delete mode 100644 api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py create mode 100644 api/tests/unit_tests/services/test_network_access_group_billing_service.py delete mode 100644 api/tests/unit_tests/services/test_network_access_policy_billing_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 28db0336e13d55..74d037101e9f89 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -155,7 +155,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError) members, model_providers, models, - network_access_policy, + network_access_group, plugin, rbac, snippets, @@ -220,7 +220,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError) "model_config", "model_providers", "models", - "network_access_policy", + "network_access_group", "notification", "oauth", "oauth_server", diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py new file mode 100644 index 00000000000000..f22f6399f98292 --- /dev/null +++ b/api/controllers/console/workspace/network_access_group.py @@ -0,0 +1,369 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, Field, ValidationError +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable + +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import ( + account_initialization_required, + model_validate, + only_edition_cloud, + setup_required, + with_current_tenant_id, + with_current_user, +) +from extensions.ext_database import db +from fields.base import ResponseModel +from libs.helper import dump_response +from libs.login import login_required +from models import Account, App +from services.billing_service import BillingService, NetworkAccessGroupUpstreamError + +NetworkAccessGroupMode = Literal["disabled", "shadow", "enforce"] + + +class NetworkAccessGroupCreatePayload(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=500) + mode: NetworkAccessGroupMode + allowed_cidrs: list[str] = Field(max_length=100) + + +class NetworkAccessGroupUpdatePayload(NetworkAccessGroupCreatePayload): + expected_version: int = Field(ge=1) + + +class NetworkAccessGroupDeleteQuery(BaseModel): + expected_version: int = Field(ge=1, description="Current group version used for optimistic concurrency") + + +class AppNetworkAccessGroupUpdatePayload(BaseModel): + group_id: UUID | None + expected_version: int = Field(ge=0) + + +class NetworkAccessGroupResponse(ResponseModel): + id: str + tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) + name: str + description: str = "" + mode: NetworkAccessGroupMode + allowed_cidrs: list[str] = Field( + default_factory=list, + validation_alias=AliasChoices("allowed_cidrs", "allowedCidrs"), + ) + version: int = Field(ge=1) + updated_by_account_id: str | None = Field( + default=None, + validation_alias=AliasChoices("updated_by_account_id", "updatedByAccountId"), + ) + created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt")) + updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt")) + + +class NetworkAccessGroupListResponse(ResponseModel): + tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) + entitled: bool + groups: list[NetworkAccessGroupResponse] + + +class NetworkAccessGroupMutationResponse(ResponseModel): + group: NetworkAccessGroupResponse + + +class NetworkAccessGroupDeleteResponse(ResponseModel): + deleted: bool + + +class AppNetworkAccessGroupBindingResponse(ResponseModel): + id: str + tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) + app_id: str = Field(validation_alias=AliasChoices("app_id", "appId")) + group_id: str | None = Field(default=None, validation_alias=AliasChoices("group_id", "groupId")) + version: int = Field(ge=1) + updated_by_account_id: str | None = Field( + default=None, + validation_alias=AliasChoices("updated_by_account_id", "updatedByAccountId"), + ) + created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt")) + updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt")) + + +class AppNetworkAccessGroupResponse(ResponseModel): + tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) + app_id: str = Field(validation_alias=AliasChoices("app_id", "appId")) + entitled: bool + binding: AppNetworkAccessGroupBindingResponse | None + + +class AppNetworkAccessGroupMutationResponse(ResponseModel): + binding: AppNetworkAccessGroupBindingResponse + + +register_schema_models( + console_ns, + NetworkAccessGroupCreatePayload, + NetworkAccessGroupUpdatePayload, + AppNetworkAccessGroupUpdatePayload, +) +register_response_schema_models( + console_ns, + NetworkAccessGroupResponse, + NetworkAccessGroupListResponse, + NetworkAccessGroupMutationResponse, + NetworkAccessGroupDeleteResponse, + AppNetworkAccessGroupBindingResponse, + AppNetworkAccessGroupResponse, + AppNetworkAccessGroupMutationResponse, +) + + +def _ensure_workspace_admin_or_owner(current_user: Account) -> None: + """Authorize management against the persisted current-workspace membership.""" + + try: + BillingService.is_tenant_owner_or_admin(current_user, session=db.session()) + except ValueError as exc: + raise Forbidden("Only workspace owners and administrators can manage network access groups.") from exc + + +def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception: + if exc.reason == "INVALID_SECRET_KEY": + return ServiceUnavailable("Network access group service authentication failed.") + if exc.status_code in (400, 422): + return BadRequest("Invalid network access group request.") + if exc.status_code == 401: + return ServiceUnavailable("Network access group service authentication failed.") + if exc.status_code == 403: + return Forbidden("Network access groups are not available for this workspace.") + if exc.status_code == 404: + return NotFound("Network access group was not found.") + if exc.status_code == 409: + message_by_reason = { + "NETWORK_ACCESS_VERSION_CONFLICT": "The network access resource changed. Refresh it and try again.", + "NETWORK_ACCESS_GROUP_NAME_CONFLICT": "A network access group with this name already exists.", + "NETWORK_ACCESS_GROUP_LIMIT": "This workspace has reached the network access group limit.", + "NETWORK_ACCESS_GROUP_BOUND": ("Unassign this network access group from every app before deleting it."), + } + return Conflict(message_by_reason.get(exc.reason or "", "The network access resource is in conflict.")) + if exc.status_code >= 500: + return ServiceUnavailable("Network access group service is unavailable.") + return BadGateway("Unexpected response from the network access group service.") + + +def _serialize_response(response_model: type[ResponseModel], payload: dict): + try: + return dump_response(response_model, payload) + except ValidationError as exc: + raise BadGateway("Invalid response from the network access group service.") from exc + + +@console_ns.route("/workspaces/current/network-access-groups") +class CurrentWorkspaceNetworkAccessGroupsApi(Resource): + @console_ns.response( + 200, + "Workspace network access groups retrieved successfully", + console_ns.models[NetworkAccessGroupListResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.list_network_access_groups(current_tenant_id, current_user.id) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessGroupListResponse, payload) + + @console_ns.expect(console_ns.models[NetworkAccessGroupCreatePayload.__name__]) + @console_ns.response( + 201, + "Workspace network access group created successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @model_validate(NetworkAccessGroupCreatePayload) + def post( + self, + req_data: NetworkAccessGroupCreatePayload, + current_tenant_id: str, + current_user: Account, + ): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.create_network_access_group( + current_tenant_id, + name=req_data.name, + description=req_data.description, + mode=req_data.mode, + allowed_cidrs=req_data.allowed_cidrs, + actor_account_id=current_user.id, + ) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, payload), 201 + + +@console_ns.route("/workspaces/current/network-access-groups/") +class CurrentWorkspaceNetworkAccessGroupApi(Resource): + @console_ns.response( + 200, + "Workspace network access group retrieved successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user: Account, group_id: UUID): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.get_network_access_group(current_tenant_id, str(group_id), current_user.id) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, payload) + + @console_ns.expect(console_ns.models[NetworkAccessGroupUpdatePayload.__name__]) + @console_ns.response( + 200, + "Workspace network access group updated successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @model_validate(NetworkAccessGroupUpdatePayload) + def put( + self, + req_data: NetworkAccessGroupUpdatePayload, + current_tenant_id: str, + current_user: Account, + group_id: UUID, + ): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.update_network_access_group( + current_tenant_id, + str(group_id), + name=req_data.name, + description=req_data.description, + mode=req_data.mode, + allowed_cidrs=req_data.allowed_cidrs, + expected_version=req_data.expected_version, + actor_account_id=current_user.id, + ) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, payload) + + @console_ns.doc(params=query_params_from_model(NetworkAccessGroupDeleteQuery)) + @console_ns.response( + 200, + "Workspace network access group deleted successfully", + console_ns.models[NetworkAccessGroupDeleteResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @model_validate(NetworkAccessGroupDeleteQuery) + def delete( + self, + req_data: NetworkAccessGroupDeleteQuery, + current_tenant_id: str, + current_user: Account, + group_id: UUID, + ): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.delete_network_access_group( + current_tenant_id, + str(group_id), + expected_version=req_data.expected_version, + actor_account_id=current_user.id, + ) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(NetworkAccessGroupDeleteResponse, payload) + + +@console_ns.route("/apps//network-access-group") +class AppNetworkAccessGroupApi(Resource): + @console_ns.response( + 200, + "App network access group binding retrieved successfully", + console_ns.models[AppNetworkAccessGroupResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @get_app_model + def get(self, current_tenant_id: str, current_user: Account, app_model: App): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.get_app_network_access_group( + current_tenant_id, + str(app_model.id), + current_user.id, + ) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(AppNetworkAccessGroupResponse, payload) + + @console_ns.expect(console_ns.models[AppNetworkAccessGroupUpdatePayload.__name__]) + @console_ns.response( + 200, + "App network access group binding updated successfully", + console_ns.models[AppNetworkAccessGroupMutationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @only_edition_cloud + @with_current_user + @with_current_tenant_id + @get_app_model + @model_validate(AppNetworkAccessGroupUpdatePayload) + def put( + self, + req_data: AppNetworkAccessGroupUpdatePayload, + current_tenant_id: str, + current_user: Account, + app_model: App, + ): + _ensure_workspace_admin_or_owner(current_user) + try: + payload = BillingService.update_app_network_access_group( + current_tenant_id, + str(app_model.id), + group_id=str(req_data.group_id) if req_data.group_id is not None else None, + expected_version=req_data.expected_version, + actor_account_id=current_user.id, + ) + except NetworkAccessGroupUpstreamError as exc: + raise _translate_upstream_error(exc) from exc + return _serialize_response(AppNetworkAccessGroupMutationResponse, payload) diff --git a/api/controllers/console/workspace/network_access_policy.py b/api/controllers/console/workspace/network_access_policy.py deleted file mode 100644 index 946607d4f5a5c9..00000000000000 --- a/api/controllers/console/workspace/network_access_policy.py +++ /dev/null @@ -1,150 +0,0 @@ -from datetime import datetime -from typing import Literal - -from flask_restx import Resource -from pydantic import AliasChoices, BaseModel, Field, ValidationError -from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable - -from controllers.common.schema import register_response_schema_models, register_schema_models -from controllers.console import console_ns -from controllers.console.wraps import ( - account_initialization_required, - model_validate, - only_edition_cloud, - setup_required, - with_current_tenant_id, - with_current_user, -) -from extensions.ext_database import db -from fields.base import ResponseModel -from libs.helper import dump_response -from libs.login import login_required -from models import Account -from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError - -NetworkAccessPolicyScope = Literal["service_api", "mcp", "workflow_webhook", "plugin_trigger"] -NetworkAccessPolicyMode = Literal["disabled", "shadow", "enforce"] - - -class NetworkAccessPolicyPayload(BaseModel): - scope: NetworkAccessPolicyScope - mode: NetworkAccessPolicyMode - allowed_cidrs: list[str] = Field(max_length=100) - expected_version: int = Field(ge=0) - - -class NetworkAccessPolicyResponse(ResponseModel): - scope: NetworkAccessPolicyScope - mode: NetworkAccessPolicyMode - allowed_cidrs: list[str] = Field(validation_alias=AliasChoices("allowed_cidrs", "allowedCidrs")) - version: int = Field(ge=0) - updated_by_account_id: str | None = Field( - default=None, - validation_alias=AliasChoices("updated_by_account_id", "updatedByAccountId"), - ) - created_at: datetime | None = Field(default=None, validation_alias=AliasChoices("created_at", "createdAt")) - updated_at: datetime | None = Field(default=None, validation_alias=AliasChoices("updated_at", "updatedAt")) - - -class NetworkAccessPolicyListResponse(ResponseModel): - tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) - entitled: bool - policies: list[NetworkAccessPolicyResponse] - - -class NetworkAccessPolicyUpdateResponse(ResponseModel): - policy: NetworkAccessPolicyResponse - - -register_schema_models(console_ns, NetworkAccessPolicyPayload) -register_response_schema_models( - console_ns, - NetworkAccessPolicyResponse, - NetworkAccessPolicyListResponse, - NetworkAccessPolicyUpdateResponse, -) - - -def _ensure_workspace_admin_or_owner(current_user: Account) -> None: - """Authorize against persisted workspace membership, including when RBAC is enabled.""" - - try: - BillingService.is_tenant_owner_or_admin(current_user, session=db.session()) - except ValueError as exc: - raise Forbidden("Only workspace owners and administrators can manage network access policies.") from exc - - -def _translate_upstream_error(exc: NetworkAccessPolicyUpstreamError) -> Exception: - if exc.status_code in (400, 422): - return BadRequest("Invalid network access policy.") - if exc.status_code in (401, 403): - return Forbidden("Network access policy is not available for this workspace.") - if exc.status_code == 404: - return NotFound("Workspace network access policy was not found.") - if exc.status_code == 409: - return Conflict("The network access policy changed. Refresh it and try again.") - if exc.status_code >= 500: - return ServiceUnavailable("Network access policy service is unavailable.") - return BadGateway("Unexpected response from the network access policy service.") - - -def _serialize_response(response_model: type[ResponseModel], payload: dict): - try: - return dump_response(response_model, payload) - except ValidationError as exc: - raise BadGateway("Invalid response from the network access policy service.") from exc - - -@console_ns.route("/workspaces/current/network-access-policy") -class CurrentWorkspaceNetworkAccessPolicyApi(Resource): - @console_ns.response( - 200, - "Workspace network access policies retrieved successfully", - console_ns.models[NetworkAccessPolicyListResponse.__name__], - ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account): - _ensure_workspace_admin_or_owner(current_user) - try: - payload = BillingService.get_network_access_policies(current_tenant_id, current_user.id) - except NetworkAccessPolicyUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - return _serialize_response(NetworkAccessPolicyListResponse, payload) - - @console_ns.expect(console_ns.models[NetworkAccessPolicyPayload.__name__]) - @console_ns.response( - 200, - "Workspace network access policy updated successfully", - console_ns.models[NetworkAccessPolicyUpdateResponse.__name__], - ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud - @with_current_user - @with_current_tenant_id - @model_validate(NetworkAccessPolicyPayload) - def put( - self, - req_data: NetworkAccessPolicyPayload, - current_tenant_id: str, - current_user: Account, - ): - _ensure_workspace_admin_or_owner(current_user) - try: - payload = BillingService.update_network_access_policy( - current_tenant_id, - req_data.scope, - mode=req_data.mode, - allowed_cidrs=req_data.allowed_cidrs, - expected_version=req_data.expected_version, - actor_account_id=current_user.id, - ) - except NetworkAccessPolicyUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - return _serialize_response(NetworkAccessPolicyUpdateResponse, payload) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index b474afac4632dc..042e383672f64b 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -2810,6 +2810,38 @@ Check if app name is available | ---- | ----------- | ------ | | 200 | Name availability checked | **application/json**: [AppDetail](#appdetail)
| +### [GET] /apps/{app_id}/network-access-group +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App network access group binding retrieved successfully | **application/json**: [AppNetworkAccessGroupResponse](#appnetworkaccessgroupresponse)
| + +### [PUT] /apps/{app_id}/network-access-group +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppNetworkAccessGroupUpdatePayload](#appnetworkaccessgroupupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App network access group binding updated successfully | **application/json**: [AppNetworkAccessGroupMutationResponse](#appnetworkaccessgroupmutationresponse)
| + ### [POST] /apps/{app_id}/publish-to-creators-platform **Publish app to Creators Platform** @@ -10698,25 +10730,71 @@ Update a plugin endpoint | ---- | ----------- | ------ | | 200 | Available models retrieved successfully | **application/json**: [AvailableModelListResponse](#availablemodellistresponse)
| -### [GET] /workspaces/current/network-access-policy +### [GET] /workspaces/current/network-access-groups #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace network access policies retrieved successfully | **application/json**: [NetworkAccessPolicyListResponse](#networkaccesspolicylistresponse)
| +| 200 | Workspace network access groups retrieved successfully | **application/json**: [NetworkAccessGroupListResponse](#networkaccessgrouplistresponse)
| + +### [POST] /workspaces/current/network-access-groups +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NetworkAccessGroupCreatePayload](#networkaccessgroupcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Workspace network access group created successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| + +### [DELETE] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| expected_version | query | Current group version used for optimistic concurrency | Yes | integer | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access group deleted successfully | **application/json**: [NetworkAccessGroupDeleteResponse](#networkaccessgroupdeleteresponse)
| + +### [GET] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access group retrieved successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| + +### [PUT] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | -### [PUT] /workspaces/current/network-access-policy #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [NetworkAccessPolicyPayload](#networkaccesspolicypayload)
| +| Yes | **application/json**: [NetworkAccessGroupUpdatePayload](#networkaccessgroupupdatepayload)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workspace network access policy updated successfully | **application/json**: [NetworkAccessPolicyUpdateResponse](#networkaccesspolicyupdateresponse)
| +| 200 | Workspace network access group updated successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| ### [GET] /workspaces/current/permission **Get workspace permission settings** @@ -15237,6 +15315,41 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | name | string | Name to check | Yes | +#### AppNetworkAccessGroupBindingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| created_at | dateTime | | Yes | +| group_id | string | | No | +| id | string | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| updated_by_account_id | string | | No | +| version | integer | | Yes | + +#### AppNetworkAccessGroupMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | + +#### AppNetworkAccessGroupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | +| entitled | boolean | | Yes | +| tenant_id | string | | Yes | + +#### AppNetworkAccessGroupUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| expected_version | integer | | Yes | +| group_id | string | | Yes | + #### AppPagination | Name | Type | Description | Required | @@ -19107,40 +19220,59 @@ Model with provider entity. | dataset | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | | workspace | [WorkspacePermissionSnapshot](#workspacepermissionsnapshot) | | No | -#### NetworkAccessPolicyListResponse +#### NetworkAccessGroupCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| description | string | | No | +| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | +| name | string | | Yes | + +#### NetworkAccessGroupDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deleted | boolean | | Yes | + +#### NetworkAccessGroupListResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | entitled | boolean | | Yes | -| policies | [ [NetworkAccessPolicyResponse](#networkaccesspolicyresponse) ] | | Yes | +| groups | [ [NetworkAccessGroupResponse](#networkaccessgroupresponse) ] | | Yes | | tenant_id | string | | Yes | -#### NetworkAccessPolicyPayload +#### NetworkAccessGroupMutationResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| allowed_cidrs | [ string ] | | Yes | -| expected_version | integer | | Yes | -| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | -| scope | string,
**Available values:** "mcp", "plugin_trigger", "service_api", "workflow_webhook" | *Enum:* `"mcp"`, `"plugin_trigger"`, `"service_api"`, `"workflow_webhook"` | Yes | +| group | [NetworkAccessGroupResponse](#networkaccessgroupresponse) | | Yes | -#### NetworkAccessPolicyResponse +#### NetworkAccessGroupResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| allowed_cidrs | [ string ] | | Yes | -| created_at | string | | No | +| allowed_cidrs | [ string ] | | No | +| created_at | dateTime | | Yes | +| description | string | | No | +| id | string | | Yes | | mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | -| scope | string,
**Available values:** "mcp", "plugin_trigger", "service_api", "workflow_webhook" | *Enum:* `"mcp"`, `"plugin_trigger"`, `"service_api"`, `"workflow_webhook"` | Yes | -| updated_at | string | | No | +| name | string | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | | updated_by_account_id | string | | No | | version | integer | | Yes | -#### NetworkAccessPolicyUpdateResponse +#### NetworkAccessGroupUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| policy | [NetworkAccessPolicyResponse](#networkaccesspolicyresponse) | | Yes | +| allowed_cidrs | [ string ] | | Yes | +| description | string | | No | +| expected_version | integer | | Yes | +| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | +| name | string | | Yes | #### NewAppResponse diff --git a/api/services/billing_service.py b/api/services/billing_service.py index caebe84b92461c..0b0359a53d3582 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -199,12 +199,13 @@ class DismissNotificationDict(TypedDict): success: bool -class NetworkAccessPolicyUpstreamError(Exception): - """Failure returned by the SaaS network-access-policy control plane.""" +class NetworkAccessGroupUpstreamError(Exception): + """Failure returned by the SaaS network-access-group control plane.""" - def __init__(self, status_code: int): + def __init__(self, status_code: int, reason: str | None = None): self.status_code = status_code - super().__init__(f"network access policy upstream returned HTTP {status_code}") + self.reason = reason + super().__init__(f"network access group upstream returned HTTP {status_code}") class BillingService: @@ -427,32 +428,76 @@ def get_tenant_feature_plan_usage(cls, tenant_id: str, feature_key: str): return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params) @classmethod - def get_network_access_policies(cls, tenant_id: str, actor_account_id: str) -> dict[str, Any]: - """Fetch workspace network access policies from the SaaS control plane.""" + def list_network_access_groups(cls, tenant_id: str, actor_account_id: str) -> dict[str, Any]: + """List the reusable network access groups owned by one workspace.""" - return cls._send_network_access_policy_request( + return cls._send_network_access_group_request( "GET", - f"/tenants/{tenant_id}/network-access-policies", + f"/tenants/{tenant_id}/network-access-groups", params={"actor_account_id": actor_account_id}, ) @classmethod - def update_network_access_policy( + def create_network_access_group( cls, tenant_id: str, - scope: str, *, + name: str, + description: str, + mode: str, + allowed_cidrs: list[str], + actor_account_id: str, + ) -> dict[str, Any]: + """Create a reusable group; the Console BFF injects the authenticated actor.""" + + return cls._send_network_access_group_request( + "POST", + f"/tenants/{tenant_id}/network-access-groups", + payload_json={ + "name": name, + "description": description, + "mode": mode, + "allowed_cidrs": allowed_cidrs, + "actor_account_id": actor_account_id, + }, + ) + + @classmethod + def get_network_access_group( + cls, + tenant_id: str, + group_id: str, + actor_account_id: str, + ) -> dict[str, Any]: + """Fetch one reusable group scoped to its owning workspace.""" + + return cls._send_network_access_group_request( + "GET", + f"/tenants/{tenant_id}/network-access-groups/{group_id}", + params={"actor_account_id": actor_account_id}, + ) + + @classmethod + def update_network_access_group( + cls, + tenant_id: str, + group_id: str, + *, + name: str, + description: str, mode: str, allowed_cidrs: list[str], expected_version: int, actor_account_id: str, ) -> dict[str, Any]: - """Replace one workspace policy; the authenticated account is injected by the Console BFF.""" + """Replace a reusable group using optimistic concurrency control.""" - return cls._send_network_access_policy_request( + return cls._send_network_access_group_request( "PUT", - f"/tenants/{tenant_id}/network-access-policies/{scope}", + f"/tenants/{tenant_id}/network-access-groups/{group_id}", payload_json={ + "name": name, + "description": description, "mode": mode, "allowed_cidrs": allowed_cidrs, "expected_version": expected_version, @@ -461,36 +506,100 @@ def update_network_access_policy( ) @classmethod - def _send_network_access_policy_request( + def delete_network_access_group( + cls, + tenant_id: str, + group_id: str, + *, + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + """Delete an unbound reusable group using optimistic concurrency control.""" + + return cls._send_network_access_group_request( + "DELETE", + f"/tenants/{tenant_id}/network-access-groups/{group_id}", + params={ + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @classmethod + def get_app_network_access_group( + cls, + tenant_id: str, + app_id: str, + actor_account_id: str, + ) -> dict[str, Any]: + """Fetch the reusable group currently assigned to one tenant-scoped app.""" + + return cls._send_network_access_group_request( + "GET", + f"/tenants/{tenant_id}/apps/{app_id}/network-access-group", + params={"actor_account_id": actor_account_id}, + ) + + @classmethod + def update_app_network_access_group( cls, - method: Literal["GET", "PUT"], + tenant_id: str, + app_id: str, + *, + group_id: str | None, + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + """Assign or unassign one reusable group from a tenant-scoped app.""" + + return cls._send_network_access_group_request( + "PUT", + f"/tenants/{tenant_id}/apps/{app_id}/network-access-group", + payload_json={ + "group_id": group_id, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @classmethod + def _send_network_access_group_request( + cls, + method: Literal["GET", "POST", "PUT", "DELETE"], endpoint: str, *, payload_json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Call the SaaS policy API while preserving its status for the Console boundary.""" + """Call the SaaS group API while preserving its status for the Console boundary.""" try: - response = cls._send_network_access_policy_http_request( + response = cls._send_network_access_group_http_request( method, endpoint, payload_json=payload_json, params=params, ) except httpx.RequestError as exc: - raise NetworkAccessPolicyUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc + raise NetworkAccessGroupUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc - if response.status_code != httpx.codes.OK: - raise NetworkAccessPolicyUpstreamError(response.status_code) + if response.status_code not in (httpx.codes.OK, httpx.codes.CREATED): + reason = None + try: + error_payload = response.json() + if isinstance(error_payload, dict) and isinstance(error_payload.get("reason"), str): + reason = error_payload["reason"] + except (json.JSONDecodeError, TypeError, ValueError): + pass + raise NetworkAccessGroupUpstreamError(response.status_code, reason) try: payload = response.json() except (json.JSONDecodeError, TypeError, ValueError) as exc: - raise NetworkAccessPolicyUpstreamError(httpx.codes.BAD_GATEWAY) from exc + raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) from exc if not isinstance(payload, dict): - raise NetworkAccessPolicyUpstreamError(httpx.codes.BAD_GATEWAY) + raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) return payload @classmethod @@ -500,9 +609,9 @@ def _send_network_access_policy_request( retry=retry_if_exception_type(httpx.RequestError), reraise=True, ) - def _send_network_access_policy_http_request( + def _send_network_access_group_http_request( cls, - method: Literal["GET", "PUT"], + method: Literal["GET", "POST", "PUT", "DELETE"], endpoint: str, *, payload_json: dict[str, Any] | None = None, diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py new file mode 100644 index 00000000000000..03842355b360ee --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -0,0 +1,392 @@ +from datetime import UTC, datetime +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import patch +from uuid import UUID + +import pytest +from pydantic import ValidationError +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable + +from controllers.console.workspace.network_access_group import ( + AppNetworkAccessGroupApi, + AppNetworkAccessGroupUpdatePayload, + CurrentWorkspaceNetworkAccessGroupApi, + CurrentWorkspaceNetworkAccessGroupsApi, + NetworkAccessGroupCreatePayload, + NetworkAccessGroupDeleteQuery, + NetworkAccessGroupUpdatePayload, + _translate_upstream_error, +) +from services.billing_service import BillingService, NetworkAccessGroupUpstreamError + +TENANT_ID = "11111111-1111-4111-8111-111111111111" +ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" +APP_ID = "33333333-3333-4333-8333-333333333333" +GROUP_ID = "44444444-4444-4444-8444-444444444444" +BINDING_ID = "55555555-5555-4555-8555-555555555555" + + +def _group_payload() -> dict[str, object]: + return { + "id": GROUP_ID, + "tenantId": TENANT_ID, + "name": "Office network", + "description": "Reusable office egress addresses", + "mode": "enforce", + "allowedCidrs": ["203.0.113.7/32"], + # ProtoJSON represents int64 values as strings and timestamps as RFC 3339. + "version": "2", + "updatedByAccountId": ACCOUNT_ID, + "createdAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), + "updatedAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), + } + + +def _binding_payload(*, group_id: str | None = GROUP_ID) -> dict[str, object]: + return { + "id": BINDING_ID, + "tenantId": TENANT_ID, + "appId": APP_ID, + "groupId": group_id, + "version": "3", + "updatedByAccountId": ACCOUNT_ID, + "createdAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), + "updatedAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), + } + + +def test_list_authorizes_and_forwards_tenant_and_actor() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id=ACCOUNT_ID) + upstream_payload = {"tenantId": TENANT_ID, "entitled": True, "groups": [_group_payload()]} + + with ( + patch("controllers.console.workspace.network_access_group.db") as db, + patch.object(BillingService, "is_tenant_owner_or_admin") as authorize, + patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload) as list_groups, + ): + result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) + + authorize.assert_called_once_with(current_user, session=db.session()) + list_groups.assert_called_once_with(TENANT_ID, ACCOUNT_ID) + assert result["tenant_id"] == TENANT_ID + assert result["groups"][0]["name"] == "Office network" + assert result["groups"][0]["version"] == 2 + assert result["groups"][0]["updated_at"] == "2026-08-21T00:00:00Z" + + +def test_create_injects_actor_and_returns_created_contract() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.post) + current_user = SimpleNamespace(id=ACCOUNT_ID) + request_payload = NetworkAccessGroupCreatePayload( + name="Office network", + description="Reusable office egress addresses", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + ) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "create_network_access_group", + return_value={"group": _group_payload()}, + ) as create_group, + ): + body, status = method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=current_user, + ) + + create_group.assert_called_once_with( + TENANT_ID, + name="Office network", + description="Reusable office egress addresses", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + actor_account_id=ACCOUNT_ID, + ) + assert status == 201 + assert body["group"]["id"] == GROUP_ID + + +def test_update_group_forwards_path_id_and_expected_version() -> None: + api = CurrentWorkspaceNetworkAccessGroupApi() + method = unwrap(api.put) + current_user = SimpleNamespace(id=ACCOUNT_ID) + request_payload = NetworkAccessGroupUpdatePayload( + name="Office network", + description="Updated", + mode="shadow", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "update_network_access_group", + return_value={"group": _group_payload()}, + ) as update_group, + ): + result = method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=current_user, + group_id=UUID(GROUP_ID), + ) + + update_group.assert_called_once_with( + TENANT_ID, + GROUP_ID, + name="Office network", + description="Updated", + mode="shadow", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + assert result["group"]["version"] == 2 + + +def test_delete_group_reads_expected_version_from_query_and_injects_actor() -> None: + api = CurrentWorkspaceNetworkAccessGroupApi() + method = unwrap(api.delete) + current_user = SimpleNamespace(id=ACCOUNT_ID) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "delete_network_access_group", + return_value={"deleted": True}, + ) as delete_group, + ): + result = method( + api, + req_data=NetworkAccessGroupDeleteQuery(expected_version=2), + current_tenant_id=TENANT_ID, + current_user=current_user, + group_id=UUID(GROUP_ID), + ) + + delete_group.assert_called_once_with( + TENANT_ID, + GROUP_ID, + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + assert result == {"deleted": True} + + +def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> None: + api = AppNetworkAccessGroupApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id=ACCOUNT_ID) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) + upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True, "binding": None} + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "get_app_network_access_group", + return_value=upstream_payload, + ) as get_binding, + ): + result = method( + api, + current_tenant_id=TENANT_ID, + current_user=current_user, + app_model=app_model, + ) + + get_binding.assert_called_once_with(TENANT_ID, APP_ID, ACCOUNT_ID) + assert result["binding"] is None + + +@pytest.mark.parametrize("group_id", [GROUP_ID, None]) +def test_app_put_assigns_or_unassigns_group(group_id: str | None) -> None: + api = AppNetworkAccessGroupApi() + method = unwrap(api.put) + current_user = SimpleNamespace(id=ACCOUNT_ID) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) + request_payload = AppNetworkAccessGroupUpdatePayload(group_id=group_id, expected_version=2) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "update_app_network_access_group", + return_value={"binding": _binding_payload(group_id=group_id)}, + ) as update_binding, + ): + result = method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=current_user, + app_model=app_model, + ) + + update_binding.assert_called_once_with( + TENANT_ID, + APP_ID, + group_id=group_id, + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + assert result["binding"]["group_id"] == group_id + assert result["binding"]["version"] == 3 + + +def test_list_rejects_non_privileged_workspace_member_before_upstream_call() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id=ACCOUNT_ID) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin", side_effect=ValueError("not privileged")), + patch.object(BillingService, "list_network_access_groups") as list_groups, + pytest.raises(Forbidden), + ): + method(api, current_tenant_id=TENANT_ID, current_user=current_user) + + list_groups.assert_not_called() + + +@pytest.mark.parametrize( + ("status_code", "expected_exception"), + [ + (400, BadRequest), + (401, ServiceUnavailable), + (403, Forbidden), + (404, NotFound), + (409, Conflict), + (500, ServiceUnavailable), + (418, BadGateway), + ], +) +def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPException]) -> None: + assert isinstance(_translate_upstream_error(NetworkAccessGroupUpstreamError(status_code)), expected_exception) + + +@pytest.mark.parametrize( + ("reason", "expected_message"), + [ + ("NETWORK_ACCESS_VERSION_CONFLICT", "changed"), + ("NETWORK_ACCESS_GROUP_NAME_CONFLICT", "already exists"), + ("NETWORK_ACCESS_GROUP_LIMIT", "reached"), + ("NETWORK_ACCESS_GROUP_BOUND", "Unassign"), + ], +) +def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, expected_message: str) -> None: + error = _translate_upstream_error(NetworkAccessGroupUpstreamError(409, reason)) + assert isinstance(error, Conflict) + assert expected_message in error.description + + +def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None: + error = _translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY")) + assert isinstance(error, ServiceUnavailable) + + +def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id=ACCOUNT_ID) + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "list_network_access_groups", + return_value={"tenant_id": TENANT_ID, "entitled": True, "groups": [{"mode": "disabled"}]}, + ), + pytest.raises(BadGateway), + ): + method(api, current_tenant_id=TENANT_ID, current_user=current_user) + + +def test_response_contract_accepts_protojson_omitted_empty_group_fields() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.get) + current_user = SimpleNamespace(id=ACCOUNT_ID) + group = _group_payload() + group.pop("description") + group.pop("allowedCidrs") + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "list_network_access_groups", + return_value={"tenantId": TENANT_ID, "entitled": True, "groups": [group]}, + ), + ): + result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) + + assert result["groups"][0]["description"] == "" + assert result["groups"][0]["allowed_cidrs"] == [] + + +def test_binding_response_accepts_protojson_omitted_null_group_id() -> None: + api = AppNetworkAccessGroupApi() + method = unwrap(api.put) + current_user = SimpleNamespace(id=ACCOUNT_ID) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) + request_payload = AppNetworkAccessGroupUpdatePayload(group_id=None, expected_version=2) + binding = _binding_payload(group_id=None) + binding.pop("groupId") + + with ( + patch("controllers.console.workspace.network_access_group.db"), + patch.object(BillingService, "is_tenant_owner_or_admin"), + patch.object( + BillingService, + "update_app_network_access_group", + return_value={"binding": binding}, + ), + ): + result = method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=current_user, + app_model=app_model, + ) + + assert result["binding"]["group_id"] is None + + +@pytest.mark.parametrize( + "payload", + [ + {"name": "", "description": "", "mode": "disabled", "allowed_cidrs": []}, + {"name": "group", "description": "", "mode": "unknown", "allowed_cidrs": []}, + {"name": "group", "description": "", "mode": "disabled", "allowed_cidrs": ["127.0.0.1"] * 101}, + ], +) +def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, object]) -> None: + with pytest.raises(ValidationError): + NetworkAccessGroupCreatePayload.model_validate(payload) + + +def test_binding_payload_rejects_invalid_group_id_and_version() -> None: + with pytest.raises(ValidationError): + AppNetworkAccessGroupUpdatePayload.model_validate({"group_id": "not-a-uuid", "expected_version": -1}) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py deleted file mode 100644 index e1a384bf0f1c26..00000000000000 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_policy.py +++ /dev/null @@ -1,148 +0,0 @@ -from datetime import UTC, datetime -from inspect import unwrap -from types import SimpleNamespace -from unittest.mock import patch - -import pytest -from pydantic import ValidationError -from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable - -from controllers.console.workspace.network_access_policy import ( - CurrentWorkspaceNetworkAccessPolicyApi, - NetworkAccessPolicyPayload, - _translate_upstream_error, -) -from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError - - -def _policy_payload() -> dict[str, object]: - return { - "scope": "service_api", - "mode": "enforce", - "allowedCidrs": ["203.0.113.7/32"], - "version": "2", - "updatedByAccountId": "account-1", - "createdAt": datetime(2026, 8, 20, tzinfo=UTC).isoformat(), - "updatedAt": datetime(2026, 8, 20, tzinfo=UTC).isoformat(), - } - - -def test_get_authorizes_and_forwards_tenant_and_actor() -> None: - api = CurrentWorkspaceNetworkAccessPolicyApi() - method = unwrap(api.get) - current_user = SimpleNamespace(id="account-1") - upstream_payload = {"tenantId": "tenant-1", "entitled": True, "policies": [_policy_payload()]} - - with ( - patch("controllers.console.workspace.network_access_policy.db") as db, - patch.object(BillingService, "is_tenant_owner_or_admin") as authorize, - patch.object(BillingService, "get_network_access_policies", return_value=upstream_payload) as get_policies, - ): - result = method(api, current_tenant_id="tenant-1", current_user=current_user) - - authorize.assert_called_once_with(current_user, session=db.session()) - get_policies.assert_called_once_with("tenant-1", "account-1") - assert result["tenant_id"] == "tenant-1" - assert result["policies"][0]["scope"] == "service_api" - assert result["policies"][0]["updated_at"] == "2026-08-20T00:00:00Z" - - -def test_put_injects_actor_and_does_not_accept_it_from_payload() -> None: - api = CurrentWorkspaceNetworkAccessPolicyApi() - method = unwrap(api.put) - current_user = SimpleNamespace(id="account-1") - request_payload = NetworkAccessPolicyPayload( - scope="service_api", - mode="enforce", - allowed_cidrs=["203.0.113.7/32"], - expected_version=1, - ) - - with ( - patch("controllers.console.workspace.network_access_policy.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), - patch.object( - BillingService, - "update_network_access_policy", - return_value={"policy": _policy_payload()}, - ) as update_policy, - ): - result = method( - api, - req_data=request_payload, - current_tenant_id="tenant-1", - current_user=current_user, - ) - - update_policy.assert_called_once_with( - "tenant-1", - "service_api", - mode="enforce", - allowed_cidrs=["203.0.113.7/32"], - expected_version=1, - actor_account_id="account-1", - ) - assert result["policy"]["version"] == 2 - - -def test_get_rejects_non_privileged_workspace_member_before_upstream_call() -> None: - api = CurrentWorkspaceNetworkAccessPolicyApi() - method = unwrap(api.get) - current_user = SimpleNamespace(id="account-1") - - with ( - patch("controllers.console.workspace.network_access_policy.db"), - patch.object(BillingService, "is_tenant_owner_or_admin", side_effect=ValueError("not privileged")), - patch.object(BillingService, "get_network_access_policies") as get_policies, - pytest.raises(Forbidden), - ): - method(api, current_tenant_id="tenant-1", current_user=current_user) - - get_policies.assert_not_called() - - -@pytest.mark.parametrize( - ("status_code", "expected_exception"), - [ - (400, BadRequest), - (403, Forbidden), - (404, NotFound), - (409, Conflict), - (500, ServiceUnavailable), - (418, BadGateway), - ], -) -def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPException]) -> None: - assert isinstance(_translate_upstream_error(NetworkAccessPolicyUpstreamError(status_code)), expected_exception) - - -def test_get_maps_invalid_upstream_contract_to_bad_gateway() -> None: - api = CurrentWorkspaceNetworkAccessPolicyApi() - method = unwrap(api.get) - current_user = SimpleNamespace(id="account-1") - - with ( - patch("controllers.console.workspace.network_access_policy.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), - patch.object( - BillingService, - "get_network_access_policies", - return_value={"tenant_id": "tenant-1", "entitled": True, "policies": [{"mode": "disabled"}]}, - ), - pytest.raises(BadGateway), - ): - method(api, current_tenant_id="tenant-1", current_user=current_user) - - -@pytest.mark.parametrize( - "payload", - [ - {"scope": "unknown", "mode": "disabled", "allowed_cidrs": [], "expected_version": 0}, - {"scope": "mcp", "mode": "unknown", "allowed_cidrs": [], "expected_version": 0}, - {"scope": "mcp", "mode": "disabled", "allowed_cidrs": [], "expected_version": -1}, - {"scope": "mcp", "mode": "disabled", "allowed_cidrs": ["127.0.0.1"] * 101, "expected_version": 0}, - ], -) -def test_payload_contract_rejects_invalid_shapes(payload: dict[str, object]) -> None: - with pytest.raises(ValidationError): - NetworkAccessPolicyPayload.model_validate(payload) diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py new file mode 100644 index 00000000000000..e71e8e98916453 --- /dev/null +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -0,0 +1,239 @@ +from collections.abc import Iterator +from unittest.mock import MagicMock, call, patch + +import httpx +import pytest + +from services.billing_service import BillingService, NetworkAccessGroupUpstreamError + +TENANT_ID = "11111111-1111-4111-8111-111111111111" +ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" +APP_ID = "33333333-3333-4333-8333-333333333333" +GROUP_ID = "44444444-4444-4444-8444-444444444444" +HEADERS = {"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"} + + +@pytest.fixture +def billing_config() -> Iterator[None]: + with ( + patch.object(BillingService, "base_url", "https://billing.internal/v1"), + patch.object(BillingService, "secret_key", "test-secret"), + ): + yield + + +@pytest.mark.usefixtures("billing_config") +def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.side_effect = [ + {"tenant_id": TENANT_ID, "entitled": True, "groups": []}, + {"group": {"id": GROUP_ID}}, + ] + + with patch("services.billing_service._http_client.request", return_value=response) as request: + list_result = BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) + item_result = BillingService.get_network_access_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + + assert list_result["groups"] == [] + assert item_result["group"]["id"] == GROUP_ID + assert request.call_args_list == [ + call( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + call( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +@pytest.mark.usefixtures("billing_config") +def test_group_mutations_inject_actor_and_use_expected_version() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.side_effect = [ + {"group": {"id": GROUP_ID, "version": "1"}}, + {"group": {"id": GROUP_ID, "version": "2"}}, + {"deleted": True}, + ] + + with patch("services.billing_service._http_client.request", return_value=response) as request: + BillingService.create_network_access_group( + TENANT_ID, + name="Office", + description="Office egress", + mode="enforce", + allowed_cidrs=["203.0.113.7/32"], + actor_account_id=ACCOUNT_ID, + ) + BillingService.update_network_access_group( + TENANT_ID, + GROUP_ID, + name="Office", + description="Updated office egress", + mode="shadow", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + BillingService.delete_network_access_group( + TENANT_ID, + GROUP_ID, + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + + assert request.call_args_list == [ + call( + "POST", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json={ + "name": "Office", + "description": "Office egress", + "mode": "enforce", + "allowed_cidrs": ["203.0.113.7/32"], + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json={ + "name": "Office", + "description": "Updated office egress", + "mode": "shadow", + "allowed_cidrs": ["203.0.113.0/24"], + "expected_version": 1, + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "DELETE", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json=None, + params={"expected_version": 2, "actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +@pytest.mark.usefixtures("billing_config") +def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.side_effect = [ + {"tenant_id": TENANT_ID, "app_id": APP_ID, "entitled": True, "binding": None}, + {"binding": {"app_id": APP_ID, "group_id": GROUP_ID, "version": "1"}}, + {"binding": {"app_id": APP_ID, "group_id": None, "version": "2"}}, + ] + + with patch("services.billing_service._http_client.request", return_value=response) as request: + BillingService.get_app_network_access_group(TENANT_ID, APP_ID, ACCOUNT_ID) + BillingService.update_app_network_access_group( + TENANT_ID, + APP_ID, + group_id=GROUP_ID, + expected_version=0, + actor_account_id=ACCOUNT_ID, + ) + BillingService.update_app_network_access_group( + TENANT_ID, + APP_ID, + group_id=None, + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + + endpoint = f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group" + assert request.call_args_list == [ + call( + "GET", + endpoint, + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + endpoint, + json={"group_id": GROUP_ID, "expected_version": 0, "actor_account_id": ACCOUNT_ID}, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + endpoint, + json={"group_id": None, "expected_version": 1, "actor_account_id": ACCOUNT_ID}, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500]) +def test_network_access_group_request_preserves_upstream_status(status_code: int) -> None: + response = MagicMock(status_code=status_code) + with ( + patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + BillingService._send_network_access_group_request("GET", "/groups") + + assert exc_info.value.status_code == status_code + + +def test_network_access_group_request_preserves_whitelisted_upstream_reason() -> None: + response = MagicMock(status_code=httpx.codes.CONFLICT) + response.json.return_value = {"code": 409, "reason": "NETWORK_ACCESS_GROUP_BOUND", "message": "internal"} + with ( + patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + BillingService._send_network_access_group_request("DELETE", "/groups/id") + + assert exc_info.value.status_code == httpx.codes.CONFLICT + assert exc_info.value.reason == "NETWORK_ACCESS_GROUP_BOUND" + + +def test_network_access_group_request_maps_transport_failure_to_service_unavailable() -> None: + request = httpx.Request("GET", "https://billing.internal/v1/groups") + with ( + patch.object( + BillingService, + "_send_network_access_group_http_request", + side_effect=httpx.ConnectError("unavailable", request=request), + ), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + BillingService._send_network_access_group_request("GET", "/groups") + + assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE + + +def test_network_access_group_request_rejects_non_object_response() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = list[object]() + with ( + patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + BillingService._send_network_access_group_request("GET", "/groups") + + assert exc_info.value.status_code == httpx.codes.BAD_GATEWAY diff --git a/api/tests/unit_tests/services/test_network_access_policy_billing_service.py b/api/tests/unit_tests/services/test_network_access_policy_billing_service.py deleted file mode 100644 index 550181795ea3dc..00000000000000 --- a/api/tests/unit_tests/services/test_network_access_policy_billing_service.py +++ /dev/null @@ -1,113 +0,0 @@ -from collections.abc import Iterator -from unittest.mock import MagicMock, patch - -import httpx -import pytest - -from services.billing_service import BillingService, NetworkAccessPolicyUpstreamError - - -@pytest.fixture -def billing_config() -> Iterator[None]: - with ( - patch.object(BillingService, "base_url", "https://billing.internal/v1"), - patch.object(BillingService, "secret_key", "test-secret"), - ): - yield - - -@pytest.mark.usefixtures("billing_config") -def test_get_network_access_policies_uses_authenticated_saas_endpoint() -> None: - response = MagicMock(status_code=httpx.codes.OK) - policies: list[dict[str, object]] = [] - response.json.return_value = {"tenant_id": "tenant-1", "entitled": True, "policies": policies} - - with patch("services.billing_service._http_client.request", return_value=response) as request: - result = BillingService.get_network_access_policies("tenant-1", "account-1") - - assert result == {"tenant_id": "tenant-1", "entitled": True, "policies": policies} - request.assert_called_once_with( - "GET", - "https://billing.internal/v1/tenants/tenant-1/network-access-policies", - json=None, - params={"actor_account_id": "account-1"}, - headers={"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"}, - follow_redirects=True, - ) - - -@pytest.mark.usefixtures("billing_config") -def test_update_network_access_policy_injects_actor_and_scope() -> None: - response = MagicMock(status_code=httpx.codes.OK) - response.json.return_value = { - "policy": { - "scope": "service_api", - "mode": "enforce", - "allowed_cidrs": ["203.0.113.7/32"], - "version": 2, - } - } - - with patch("services.billing_service._http_client.request", return_value=response) as request: - result = BillingService.update_network_access_policy( - "tenant-1", - "service_api", - mode="enforce", - allowed_cidrs=["203.0.113.7/32"], - expected_version=1, - actor_account_id="account-1", - ) - - assert result["policy"]["version"] == 2 - request.assert_called_once_with( - "PUT", - "https://billing.internal/v1/tenants/tenant-1/network-access-policies/service_api", - json={ - "mode": "enforce", - "allowed_cidrs": ["203.0.113.7/32"], - "expected_version": 1, - "actor_account_id": "account-1", - }, - params=None, - headers={"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"}, - follow_redirects=True, - ) - - -@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500]) -def test_network_access_policy_request_preserves_upstream_status(status_code: int) -> None: - response = MagicMock(status_code=status_code) - with ( - patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), - pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, - ): - BillingService._send_network_access_policy_request("GET", "/policies") - - assert exc_info.value.status_code == status_code - - -def test_network_access_policy_request_maps_transport_failure_to_service_unavailable() -> None: - request = httpx.Request("GET", "https://billing.internal/v1/policies") - with ( - patch.object( - BillingService, - "_send_network_access_policy_http_request", - side_effect=httpx.ConnectError("unavailable", request=request), - ), - pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, - ): - BillingService._send_network_access_policy_request("GET", "/policies") - - assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE - - -def test_network_access_policy_request_rejects_non_object_response() -> None: - response = MagicMock(status_code=httpx.codes.OK) - response.json.return_value = list[object]() - with ( - patch.object(BillingService, "_send_network_access_policy_http_request", return_value=response), - pytest.raises(NetworkAccessPolicyUpstreamError) as exc_info, - ): - BillingService._send_network_access_policy_request("GET", "/policies") - - assert exc_info.value.status_code == httpx.codes.BAD_GATEWAY diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 4c2fcd0df73e8d..7245ec0fe9f552 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -118,6 +118,8 @@ import { zGetAppsByAppIdFeedbacksExportResponse, zGetAppsByAppIdMessagesByMessageIdPath, zGetAppsByAppIdMessagesByMessageIdResponse, + zGetAppsByAppIdNetworkAccessGroupPath, + zGetAppsByAppIdNetworkAccessGroupResponse, zGetAppsByAppIdPath, zGetAppsByAppIdResponse, zGetAppsByAppIdServerPath, @@ -447,6 +449,9 @@ import { zPostAppsWorkflowsOnlineUsersBody, zPostAppsWorkflowsOnlineUsersResponse, zPutAppsByAppIdBody, + zPutAppsByAppIdNetworkAccessGroupBody, + zPutAppsByAppIdNetworkAccessGroupPath, + zPutAppsByAppIdNetworkAccessGroupResponse, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, zPutAppsByAppIdServerBody, @@ -1965,6 +1970,38 @@ export const name = { post: post26, } +export const get34 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdNetworkAccessGroup', + path: '/apps/{app_id}/network-access-group', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdNetworkAccessGroupPath })) + .output(zGetAppsByAppIdNetworkAccessGroupResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdNetworkAccessGroup', + path: '/apps/{app_id}/network-access-group', + tags: ['console'], + }) + .input( + z.object({ + body: zPutAppsByAppIdNetworkAccessGroupBody, + params: zPutAppsByAppIdNetworkAccessGroupPath, + }), + ) + .output(zPutAppsByAppIdNetworkAccessGroupResponse) + +export const networkAccessGroup = { + get: get34, + put, +} + /** * Publish app to Creators Platform */ @@ -2006,7 +2043,7 @@ export const refresh = { /** * Get MCP server configuration for an application */ -export const get34 = oc +export const get35 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -2037,7 +2074,7 @@ export const post29 = oc /** * Update MCP server configuration for an application */ -export const put = oc +export const put2 = oc .route({ description: 'Update MCP server configuration for an application', inputStructure: 'detailed', @@ -2050,9 +2087,9 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get34, + get: get35, post: post29, - put, + put: put2, refresh, } @@ -2152,7 +2189,7 @@ export const star = { /** * Get average response time statistics for an application */ -export const get35 = oc +export const get36 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2170,13 +2207,13 @@ export const get35 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get35, + get: get36, } /** * Get average session interaction statistics for an application */ -export const get36 = oc +export const get37 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2194,13 +2231,13 @@ export const get36 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get36, + get: get37, } /** * Get daily conversation statistics for an application */ -export const get37 = oc +export const get38 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2218,13 +2255,13 @@ export const get37 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get37, + get: get38, } /** * Get daily terminal/end-user statistics for an application */ -export const get38 = oc +export const get39 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2242,13 +2279,13 @@ export const get38 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get38, + get: get39, } /** * Get daily message statistics for an application */ -export const get39 = oc +export const get40 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2266,13 +2303,13 @@ export const get39 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get39, + get: get40, } /** * Get daily token cost statistics for an application */ -export const get40 = oc +export const get41 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2290,13 +2327,13 @@ export const get40 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get40, + get: get41, } /** * Get tokens per second statistics for an application */ -export const get41 = oc +export const get42 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2314,13 +2351,13 @@ export const get41 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get41, + get: get42, } /** * Get user satisfaction rate statistics for an application */ -export const get42 = oc +export const get43 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2338,7 +2375,7 @@ export const get42 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get42, + get: get43, } export const statistics = { @@ -2355,7 +2392,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get43 = oc +export const get44 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2373,7 +2410,7 @@ export const get43 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get43, + get: get44, } /** @@ -2403,7 +2440,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get44 = oc +export const get45 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2432,7 +2469,7 @@ export const post35 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get44, + get: get45, post: post35, } @@ -2463,7 +2500,7 @@ export const delete8 = oc /** * Get tracing configuration for an application */ -export const get45 = oc +export const get46 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2520,7 +2557,7 @@ export const post36 = oc export const traceConfig = { delete: delete8, - get: get45, + get: get46, patch, post: post36, } @@ -2552,7 +2589,7 @@ export const triggerEnable = { /** * Get app triggers list */ -export const get46 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2565,7 +2602,7 @@ export const get46 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get46, + get: get47, } /** @@ -2573,7 +2610,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get47 = oc +export const get48 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2592,7 +2629,7 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get47, + get: get48, } /** @@ -2600,7 +2637,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get48 = oc +export const get49 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2619,7 +2656,7 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get48, + get: get49, } /** @@ -2627,7 +2664,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get49 = oc +export const get50 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2646,7 +2683,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get49, + get: get50, } /** @@ -2682,7 +2719,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get50 = oc +export const get51 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2695,7 +2732,7 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get50, + get: get51, } /** @@ -2703,7 +2740,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get51 = oc +export const get52 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2717,7 +2754,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get51, + get: get52, } /** @@ -2725,7 +2762,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get52 = oc +export const get53 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2739,7 +2776,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get52, + get: get53, export: export4, nodeExecutions, } @@ -2772,7 +2809,7 @@ export const download4 = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get53 = oc +export const get54 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2790,13 +2827,13 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read = { - get: get53, + get: get54, } /** * List a directory in a workflow Agent node sandbox */ -export const get54 = oc +export const get55 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2814,7 +2851,7 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) export const files3 = { - get: get54, + get: get55, download: download4, read, } @@ -2840,7 +2877,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get55 = oc +export const get56 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2859,7 +2896,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get55, + get: get56, count: count3, tasks, byRunId, @@ -2871,7 +2908,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get56 = oc +export const get57 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2885,7 +2922,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get56, + get: get57, } /** @@ -2912,7 +2949,7 @@ export const delete9 = oc * * Update a comment reply */ -export const put2 = oc +export const put3 = oc .route({ description: 'Update a comment reply', inputStructure: 'detailed', @@ -2932,7 +2969,7 @@ export const put2 = oc export const byReplyId = { delete: delete9, - put: put2, + put: put3, } /** @@ -3010,7 +3047,7 @@ export const delete10 = oc * * Get a specific workflow comment */ -export const get57 = oc +export const get58 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3028,7 +3065,7 @@ export const get57 = oc * * Update a workflow comment */ -export const put3 = oc +export const put4 = oc .route({ description: 'Update a workflow comment', inputStructure: 'detailed', @@ -3048,8 +3085,8 @@ export const put3 = oc export const byCommentId = { delete: delete10, - get: get57, - put: put3, + get: get58, + put: put4, replies, resolve, } @@ -3059,7 +3096,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get58 = oc +export const get59 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3097,7 +3134,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get58, + get: get59, post: post42, mentionUsers, byCommentId, @@ -3106,7 +3143,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get59 = oc +export const get60 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3124,13 +3161,13 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get59, + get: get60, } /** * Get workflow daily runs statistics */ -export const get60 = oc +export const get61 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3148,13 +3185,13 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get60, + get: get61, } /** * Get workflow daily terminals statistics */ -export const get61 = oc +export const get62 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3172,13 +3209,13 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get61, + get: get62, } /** * Get workflow daily token cost statistics */ -export const get62 = oc +export const get63 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3196,7 +3233,7 @@ export const get62 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get62, + get: get63, } export const statistics2 = { @@ -3216,7 +3253,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get63 = oc +export const get64 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3235,7 +3272,7 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get63, + get: get64, } /** @@ -3243,7 +3280,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get64 = oc +export const get65 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3257,14 +3294,14 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get64, + get: get65, byBlockType, } /** * Get conversation variables for workflow */ -export const get65 = oc +export const get66 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3297,7 +3334,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get65, + get: get66, post: post43, } @@ -3306,7 +3343,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get66 = oc +export const get67 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3340,7 +3377,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get66, + get: get67, post: post44, } @@ -3545,7 +3582,7 @@ export const loop2 = { nodes: nodes6, } -export const get67 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3559,7 +3596,7 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get67, + get: get68, } export const post51 = oc @@ -3642,7 +3679,7 @@ export const validate = { post: post54, } -export const get68 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3658,7 +3695,7 @@ export const get68 = oc ) .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) -export const put4 = oc +export const put5 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3675,8 +3712,8 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get68, - put: put4, + get: get69, + put: put5, candidates, copyFromRoster, impact, @@ -3687,7 +3724,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get69 = oc +export const get70 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3700,7 +3737,7 @@ export const get69 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get69, + get: get70, } /** @@ -3775,7 +3812,7 @@ export const delete11 = oc /** * Get variables for a specific node */ -export const get70 = oc +export const get71 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -3789,7 +3826,7 @@ export const get70 = oc export const variables = { delete: delete11, - get: get70, + get: get71, } export const byNodeId8 = { @@ -3834,7 +3871,7 @@ export const run10 = { /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get71 = oc +export const get72 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -3847,13 +3884,13 @@ export const get71 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get71, + get: get72, } /** * Full value for one declared output, including signed download URL for files. */ -export const get72 = oc +export const get73 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -3870,7 +3907,7 @@ export const get72 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview5 = { - get: get72, + get: get73, } export const byOutputName = { @@ -3880,7 +3917,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get73 = oc +export const get74 = oc .route({ description: "One node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -3893,14 +3930,14 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get73, + get: get74, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get74 = oc +export const get75 = oc .route({ description: "Snapshot of every node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -3913,7 +3950,7 @@ export const get74 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get74, + get: get75, events, byNodeId: byNodeId9, } @@ -3929,7 +3966,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get75 = oc +export const get76 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -3942,7 +3979,7 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get75, + get: get76, } /** @@ -4007,7 +4044,7 @@ export const trigger2 = { /** * Reset a workflow variable to its default value */ -export const put5 = oc +export const put6 = oc .route({ description: 'Reset a workflow variable to its default value', inputStructure: 'detailed', @@ -4020,7 +4057,7 @@ export const put5 = oc .output(zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse) export const reset = { - put: put5, + put: put6, } /** @@ -4042,7 +4079,7 @@ export const delete12 = oc /** * Get a specific workflow variable */ -export const get76 = oc +export const get77 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -4076,7 +4113,7 @@ export const patch2 = oc export const byVariableId = { delete: delete12, - get: get76, + get: get77, patch: patch2, reset, } @@ -4102,7 +4139,7 @@ export const delete13 = oc * * Get draft workflow variables */ -export const get77 = oc +export const get78 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4122,7 +4159,7 @@ export const get77 = oc export const variables2 = { delete: delete13, - get: get77, + get: get78, byVariableId, } @@ -4131,7 +4168,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get78 = oc +export const get79 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4168,7 +4205,7 @@ export const post60 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get78, + get: get79, post: post60, conversationVariables: conversationVariables2, environmentVariables, @@ -4189,7 +4226,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get79 = oc +export const get80 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4223,14 +4260,14 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get79, + get: get80, post: post61, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get80 = oc +export const get81 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4243,13 +4280,13 @@ export const get80 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get80, + get: get81, } /** * Full value for one declared output of a published run. */ -export const get81 = oc +export const get82 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4270,7 +4307,7 @@ export const get81 = oc ) export const preview6 = { - get: get81, + get: get82, } export const byOutputName2 = { @@ -4280,7 +4317,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get82 = oc +export const get83 = oc .route({ description: "One node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4293,14 +4330,14 @@ export const get82 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get82, + get: get83, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get83 = oc +export const get84 = oc .route({ description: "Snapshot of every node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4313,7 +4350,7 @@ export const get83 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get83, + get: get84, events: events2, byNodeId: byNodeId10, } @@ -4333,7 +4370,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get84 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4351,7 +4388,7 @@ export const get84 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get84, + get: get85, } export const triggers2 = { @@ -4427,7 +4464,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get85 = oc +export const get86 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4446,7 +4483,7 @@ export const get85 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get85, + get: get86, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4479,7 +4516,7 @@ export const delete15 = oc * * Get application details */ -export const get86 = oc +export const get87 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4497,7 +4534,7 @@ export const get86 = oc * * Update application details */ -export const put6 = oc +export const put7 = oc .route({ description: 'Update application details', inputStructure: 'detailed', @@ -4512,8 +4549,8 @@ export const put6 = oc export const byAppId2 = { delete: delete15, - get: get86, - put: put6, + get: get87, + put: put7, advancedChat, agent, annotationReply, @@ -4535,6 +4572,7 @@ export const byAppId2 = { messages, modelConfig, name, + networkAccessGroup, publishToCreatorsPlatform, server, site, @@ -4581,7 +4619,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get87 = oc +export const get88 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4614,7 +4652,7 @@ export const post63 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get87, + get: get88, post: post63, byApiKeyId, } @@ -4628,7 +4666,7 @@ export const byResourceId = { * * Get list of applications with pagination and filtering */ -export const get88 = oc +export const get89 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4661,7 +4699,7 @@ export const post64 = oc .output(zPostAppsResponse) export const apps = { - get: get88, + get: get89, post: post64, imports, recent, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 0edb77f8ec8c89..b94f4f764163ac 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -572,6 +572,22 @@ export type AppNamePayload = { name: string } +export type AppNetworkAccessGroupResponse = { + app_id: string + binding: AppNetworkAccessGroupBindingResponse | null + entitled: boolean + tenant_id: string +} + +export type AppNetworkAccessGroupUpdatePayload = { + expected_version: number + group_id: string | null +} + +export type AppNetworkAccessGroupMutationResponse = { + binding: AppNetworkAccessGroupBindingResponse +} + export type RedirectUrlResponse = { redirect_url: string } @@ -1631,6 +1647,17 @@ export type MessageFile = { url?: string | null } +export type AppNetworkAccessGroupBindingResponse = { + app_id: string + created_at: string + group_id?: string | null + id: string + tenant_id: string + updated_at: string + updated_by_account_id?: string | null + version: number +} + export type AppMcpServerStatus = 'active' | 'inactive' | 'normal' export type AverageResponseTimeStatisticItem = { @@ -4591,6 +4618,38 @@ export type PostAppsByAppIdNameResponses = { export type PostAppsByAppIdNameResponse = PostAppsByAppIdNameResponses[keyof PostAppsByAppIdNameResponses] +export type GetAppsByAppIdNetworkAccessGroupData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/network-access-group' +} + +export type GetAppsByAppIdNetworkAccessGroupResponses = { + 200: AppNetworkAccessGroupResponse +} + +export type GetAppsByAppIdNetworkAccessGroupResponse = + GetAppsByAppIdNetworkAccessGroupResponses[keyof GetAppsByAppIdNetworkAccessGroupResponses] + +export type PutAppsByAppIdNetworkAccessGroupData = { + body: AppNetworkAccessGroupUpdatePayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/network-access-group' +} + +export type PutAppsByAppIdNetworkAccessGroupResponses = { + 200: AppNetworkAccessGroupMutationResponse +} + +export type PutAppsByAppIdNetworkAccessGroupResponse = + PutAppsByAppIdNetworkAccessGroupResponses[keyof PutAppsByAppIdNetworkAccessGroupResponses] + export type PostAppsByAppIdPublishToCreatorsPlatformData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 91074f82e8beb4..299259af22cf71 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -346,6 +346,14 @@ export const zAppNamePayload = z.object({ name: z.string().min(1), }) +/** + * AppNetworkAccessGroupUpdatePayload + */ +export const zAppNetworkAccessGroupUpdatePayload = z.object({ + expected_version: z.int().gte(0), + group_id: z.uuid().nullable(), +}) + /** * RedirectUrlResponse */ @@ -1326,6 +1334,37 @@ export const zMessageFile = z.object({ url: z.string().nullish(), }) +/** + * AppNetworkAccessGroupBindingResponse + */ +export const zAppNetworkAccessGroupBindingResponse = z.object({ + app_id: z.string(), + created_at: z.iso.datetime(), + group_id: z.string().nullish(), + id: z.string(), + tenant_id: z.string(), + updated_at: z.iso.datetime(), + updated_by_account_id: z.string().nullish(), + version: z.int().gte(1), +}) + +/** + * AppNetworkAccessGroupResponse + */ +export const zAppNetworkAccessGroupResponse = z.object({ + app_id: z.string(), + binding: zAppNetworkAccessGroupBindingResponse.nullable(), + entitled: z.boolean(), + tenant_id: z.string(), +}) + +/** + * AppNetworkAccessGroupMutationResponse + */ +export const zAppNetworkAccessGroupMutationResponse = z.object({ + binding: zAppNetworkAccessGroupBindingResponse, +}) + /** * AppMCPServerStatus * @@ -5145,6 +5184,26 @@ export const zPostAppsByAppIdNamePath = z.object({ */ export const zPostAppsByAppIdNameResponse = zAppDetail +export const zGetAppsByAppIdNetworkAccessGroupPath = z.object({ + app_id: z.uuid(), +}) + +/** + * App network access group binding retrieved successfully + */ +export const zGetAppsByAppIdNetworkAccessGroupResponse = zAppNetworkAccessGroupResponse + +export const zPutAppsByAppIdNetworkAccessGroupBody = zAppNetworkAccessGroupUpdatePayload + +export const zPutAppsByAppIdNetworkAccessGroupPath = z.object({ + app_id: z.uuid(), +}) + +/** + * App network access group binding updated successfully + */ +export const zPutAppsByAppIdNetworkAccessGroupResponse = zAppNetworkAccessGroupMutationResponse + export const zPostAppsByAppIdPublishToCreatorsPlatformPath = z.object({ app_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 499b3ef6e05ef0..adbd62faa362ed 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -18,6 +18,9 @@ import { zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, zDeleteWorkspacesCurrentModelProvidersByProviderModelsPath, zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsBody, @@ -75,7 +78,9 @@ import { zGetWorkspacesCurrentModelProvidersSummaryResponse, zGetWorkspacesCurrentModelsModelTypesByModelTypePath, zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse, - zGetWorkspacesCurrentNetworkAccessPolicyResponse, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, + zGetWorkspacesCurrentNetworkAccessGroupsResponse, zGetWorkspacesCurrentPermissionResponse, zGetWorkspacesCurrentPluginAssetQuery, zGetWorkspacesCurrentPluginAssetResponse, @@ -282,6 +287,8 @@ import { zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypePath, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse, + zPostWorkspacesCurrentNetworkAccessGroupsBody, + zPostWorkspacesCurrentNetworkAccessGroupsResponse, zPostWorkspacesCurrentPluginAutoUpgradeChangeBody, zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse, zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody, @@ -397,8 +404,9 @@ import { zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, - zPutWorkspacesCurrentNetworkAccessPolicyBody, - zPutWorkspacesCurrentNetworkAccessPolicyResponse, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath, @@ -1586,38 +1594,89 @@ export const models2 = { modelTypes, } +export const delete8 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + query: zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery, + }), + ) + .output(zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) + export const get21 = oc .route({ inputStructure: 'detailed', method: 'GET', - operationId: 'getWorkspacesCurrentNetworkAccessPolicy', - path: '/workspaces/current/network-access-policy', + operationId: 'getWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', tags: ['console'], }) - .output(zGetWorkspacesCurrentNetworkAccessPolicyResponse) + .input(z.object({ params: zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath })) + .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) export const put4 = oc .route({ inputStructure: 'detailed', method: 'PUT', - operationId: 'putWorkspacesCurrentNetworkAccessPolicy', - path: '/workspaces/current/network-access-policy', + operationId: 'putWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', tags: ['console'], }) - .input(z.object({ body: zPutWorkspacesCurrentNetworkAccessPolicyBody })) - .output(zPutWorkspacesCurrentNetworkAccessPolicyResponse) + .input( + z.object({ + body: zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody, + params: zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + }), + ) + .output(zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) -export const networkAccessPolicy = { +export const byGroupId = { + delete: delete8, get: get21, put: put4, } +export const get22 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroups', + path: '/workspaces/current/network-access-groups', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentNetworkAccessGroupsResponse) + +export const post26 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentNetworkAccessGroups', + path: '/workspaces/current/network-access-groups', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentNetworkAccessGroupsBody })) + .output(zPostWorkspacesCurrentNetworkAccessGroupsResponse) + +export const networkAccessGroups = { + get: get22, + post: post26, + byGroupId, +} + /** * Get workspace permission settings * * Returns permission flags that control workspace features like member invitations and owner transfer. */ -export const get22 = oc +export const get23 = oc .route({ description: 'Returns permission flags that control workspace features like member invitations and owner transfer.', @@ -1631,10 +1690,10 @@ export const get22 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get22, + get: get23, } -export const get23 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1646,10 +1705,10 @@ export const get23 = oc .output(zGetWorkspacesCurrentPluginAssetResponse) export const asset = { - get: get23, + get: get24, } -export const post26 = oc +export const post27 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1661,10 +1720,10 @@ export const post26 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse) export const change = { - post: post26, + post: post27, } -export const post27 = oc +export const post28 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1676,10 +1735,10 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse) export const exclude = { - post: post27, + post: post28, } -export const get24 = oc +export const get25 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1691,7 +1750,7 @@ export const get24 = oc .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) export const fetch_ = { - get: get24, + get: get25, } export const autoUpgrade = { @@ -1700,7 +1759,7 @@ export const autoUpgrade = { fetch: fetch_, } -export const get25 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1711,10 +1770,10 @@ export const get25 = oc .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) export const debuggingKey = { - get: get25, + get: get26, } -export const get26 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1726,10 +1785,10 @@ export const get26 = oc .output(zGetWorkspacesCurrentPluginFetchManifestResponse) export const fetchManifest = { - get: get26, + get: get27, } -export const get27 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1741,10 +1800,10 @@ export const get27 = oc .output(zGetWorkspacesCurrentPluginIconResponse) export const icon = { - get: get27, + get: get28, } -export const post28 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1756,10 +1815,10 @@ export const post28 = oc .output(zPostWorkspacesCurrentPluginInstallGithubResponse) export const github = { - post: post28, + post: post29, } -export const post29 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1771,10 +1830,10 @@ export const post29 = oc .output(zPostWorkspacesCurrentPluginInstallMarketplaceResponse) export const marketplace = { - post: post29, + post: post30, } -export const post30 = oc +export const post31 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1786,7 +1845,7 @@ export const post30 = oc .output(zPostWorkspacesCurrentPluginInstallPkgResponse) export const pkg = { - post: post30, + post: post31, } export const install = { @@ -1795,7 +1854,7 @@ export const install = { pkg, } -export const get28 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1807,10 +1866,10 @@ export const get28 = oc .output(zGetWorkspacesCurrentPluginInstalledIdsResponse) export const installedIds = { - get: get28, + get: get29, } -export const post31 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1822,14 +1881,14 @@ export const post31 = oc .output(zPostWorkspacesCurrentPluginListInstallationsIdsResponse) export const ids = { - post: post31, + post: post32, } export const installations = { ids, } -export const post32 = oc +export const post33 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1841,10 +1900,10 @@ export const post32 = oc .output(zPostWorkspacesCurrentPluginListLatestVersionsResponse) export const latestVersions = { - post: post32, + post: post33, } -export const get29 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1856,12 +1915,12 @@ export const get29 = oc .output(zGetWorkspacesCurrentPluginListResponse) export const list2 = { - get: get29, + get: get30, installations, latestVersions, } -export const get30 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1873,14 +1932,14 @@ export const get30 = oc .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) export const pkg2 = { - get: get30, + get: get31, } export const marketplace2 = { pkg: pkg2, } -export const get31 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1892,13 +1951,13 @@ export const get31 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get31, + get: get32, } /** * Fetch dynamic options using credentials directly (for edit mode) */ -export const post33 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1913,7 +1972,7 @@ export const post33 = oc .output(zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse) export const dynamicOptionsWithCredentials = { - post: post33, + post: post34, } export const parameters = { @@ -1921,7 +1980,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post34 = oc +export const post35 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1933,10 +1992,10 @@ export const post34 = oc .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) export const change2 = { - post: post34, + post: post35, } -export const get32 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1947,7 +2006,7 @@ export const get32 = oc .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) export const fetch2 = { - get: get32, + get: get33, } export const permission2 = { @@ -1955,7 +2014,7 @@ export const permission2 = { fetch: fetch2, } -export const get33 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1967,10 +2026,10 @@ export const get33 = oc .output(zGetWorkspacesCurrentPluginReadmeResponse) export const readme = { - get: get33, + get: get34, } -export const post35 = oc +export const post36 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1981,10 +2040,10 @@ export const post35 = oc .output(zPostWorkspacesCurrentPluginTasksDeleteAllResponse) export const deleteAll = { - post: post35, + post: post36, } -export const post36 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1996,10 +2055,10 @@ export const post36 = oc .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse) export const byIdentifier = { - post: post36, + post: post37, } -export const post37 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2010,12 +2069,12 @@ export const post37 = oc .input(z.object({ params: zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath })) .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse) -export const delete8 = { - post: post37, +export const delete9 = { + post: post38, byIdentifier, } -export const get34 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2027,11 +2086,11 @@ export const get34 = oc .output(zGetWorkspacesCurrentPluginTasksByTaskIdResponse) export const byTaskId = { - get: get34, - delete: delete8, + get: get35, + delete: delete9, } -export const get35 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2043,12 +2102,12 @@ export const get35 = oc .output(zGetWorkspacesCurrentPluginTasksResponse) export const tasks = { - get: get35, + get: get36, deleteAll, byTaskId, } -export const post38 = oc +export const post39 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2060,10 +2119,10 @@ export const post38 = oc .output(zPostWorkspacesCurrentPluginUninstallResponse) export const uninstall = { - post: post38, + post: post39, } -export const post39 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2075,10 +2134,10 @@ export const post39 = oc .output(zPostWorkspacesCurrentPluginUpgradeGithubResponse) export const github2 = { - post: post39, + post: post40, } -export const post40 = oc +export const post41 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2090,7 +2149,7 @@ export const post40 = oc .output(zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse) export const marketplace3 = { - post: post40, + post: post41, } export const upgrade = { @@ -2098,7 +2157,7 @@ export const upgrade = { marketplace: marketplace3, } -export const post41 = oc +export const post42 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2109,10 +2168,10 @@ export const post41 = oc .output(zPostWorkspacesCurrentPluginUploadBundleResponse) export const bundle = { - post: post41, + post: post42, } -export const post42 = oc +export const post43 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2124,10 +2183,10 @@ export const post42 = oc .output(zPostWorkspacesCurrentPluginUploadGithubResponse) export const github3 = { - post: post42, + post: post43, } -export const post43 = oc +export const post44 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2139,7 +2198,7 @@ export const post43 = oc .output(zPostWorkspacesCurrentPluginUploadPkgResponse) export const pkg3 = { - post: post43, + post: post44, } export const upload = { @@ -2148,7 +2207,7 @@ export const upload = { pkg: pkg3, } -export const get36 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2165,7 +2224,7 @@ export const get36 = oc .output(zGetWorkspacesCurrentPluginByCategoryListResponse) export const list3 = { - get: get36, + get: get37, } export const byCategory = { @@ -2192,7 +2251,7 @@ export const plugin2 = { byCategory, } -export const post44 = oc +export const post45 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2205,10 +2264,10 @@ export const post44 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse) export const copy = { - post: post44, + post: post45, } -export const delete9 = oc +export const delete10 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2219,7 +2278,7 @@ export const delete9 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const get37 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2242,13 +2301,13 @@ export const put5 = oc .output(zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) export const byPolicyId = { - delete: delete9, - get: get37, + delete: delete10, + get: get38, put: put5, copy, } -export const get38 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2258,7 +2317,7 @@ export const get38 = oc }) .output(zGetWorkspacesCurrentRbacAccessPoliciesResponse) -export const post45 = oc +export const post46 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2270,8 +2329,8 @@ export const post45 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) export const accessPolicies = { - get: get38, - post: post45, + get: get39, + post: post46, byPolicyId, } @@ -2314,7 +2373,7 @@ export const accessPolicyBindings = { byBindingId, } -export const delete10 = oc +export const delete11 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2330,7 +2389,7 @@ export const delete10 = oc ) .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get39 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2346,11 +2405,11 @@ export const get39 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings = { - delete: delete10, - get: get39, + delete: delete11, + get: get40, } -export const get40 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2366,7 +2425,7 @@ export const get40 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings = { - get: get40, + get: get41, } export const byPolicyId2 = { @@ -2378,7 +2437,7 @@ export const accessPolicies2 = { byPolicyId: byPolicyId2, } -export const get41 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2395,10 +2454,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) export const accessPolicy = { - get: get41, + get: get42, } -export const get42 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2415,7 +2474,7 @@ export const get42 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) export const userAccessPolicies = { - get: get42, + get: get43, } export const put8 = oc @@ -2446,7 +2505,7 @@ export const users = { byTargetAccountId, } -export const get43 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2474,7 +2533,7 @@ export const put9 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) export const whitelist = { - get: get43, + get: get44, put: put9, } @@ -2490,7 +2549,7 @@ export const apps = { byAppId, } -export const delete11 = oc +export const delete12 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2510,7 +2569,7 @@ export const delete11 = oc zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, ) -export const get44 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2530,11 +2589,11 @@ export const get44 = oc ) export const memberBindings2 = { - delete: delete11, - get: get44, + delete: delete12, + get: get45, } -export const get45 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2550,7 +2609,7 @@ export const get45 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings2 = { - get: get45, + get: get46, } export const byPolicyId3 = { @@ -2562,7 +2621,7 @@ export const accessPolicies4 = { byPolicyId: byPolicyId3, } -export const get46 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2579,10 +2638,10 @@ export const get46 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) export const accessPolicy2 = { - get: get46, + get: get47, } -export const get47 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2599,7 +2658,7 @@ export const get47 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) export const userAccessPolicies2 = { - get: get47, + get: get48, } export const put10 = oc @@ -2630,7 +2689,7 @@ export const users2 = { byTargetAccountId: byTargetAccountId2, } -export const get48 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2658,7 +2717,7 @@ export const put11 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) export const whitelist2 = { - get: get48, + get: get49, put: put11, } @@ -2674,7 +2733,7 @@ export const datasets = { byDatasetId, } -export const get49 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2702,7 +2761,7 @@ export const put12 = oc .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const rbacRoles = { - get: get49, + get: get50, put: put12, } @@ -2714,7 +2773,7 @@ export const members2 = { byMemberId: byMemberId2, } -export const get50 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2725,10 +2784,10 @@ export const get50 = oc .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) export const myPermissions = { - get: get50, + get: get51, } -export const get51 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2739,10 +2798,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) export const app = { - get: get51, + get: get52, } -export const get52 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2753,10 +2812,10 @@ export const get52 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) export const dataset = { - get: get52, + get: get53, } -export const get53 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2767,7 +2826,7 @@ export const get53 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) export const catalog = { - get: get53, + get: get54, app, dataset, } @@ -2776,7 +2835,7 @@ export const rolePermissions = { catalog, } -export const post46 = oc +export const post47 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2789,10 +2848,10 @@ export const post46 = oc .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) export const copy2 = { - post: post46, + post: post47, } -export const get54 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2804,10 +2863,10 @@ export const get54 = oc .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { - get: get54, + get: get55, } -export const delete12 = oc +export const delete13 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2818,7 +2877,7 @@ export const delete12 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) -export const get55 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2841,14 +2900,14 @@ export const put13 = oc .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) export const byRoleId = { - delete: delete12, - get: get55, + delete: delete13, + get: get56, put: put13, copy: copy2, members: members3, } -export const get56 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2858,7 +2917,7 @@ export const get56 = oc }) .output(zGetWorkspacesCurrentRbacRolesResponse) -export const post47 = oc +export const post48 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2870,8 +2929,8 @@ export const post47 = oc .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { - get: get56, - post: post47, + get: get57, + post: post48, byRoleId, } @@ -2895,7 +2954,7 @@ export const bindings = { put: put14, } -export const get57 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2911,10 +2970,10 @@ export const get57 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings3 = { - get: get57, + get: get58, } -export const get58 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2930,7 +2989,7 @@ export const get58 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings3 = { - get: get58, + get: get59, } export const byPolicyId4 = { @@ -2943,7 +3002,7 @@ export const accessPolicies6 = { byPolicyId: byPolicyId4, } -export const get59 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2954,7 +3013,7 @@ export const get59 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) export const accessPolicy3 = { - get: get59, + get: get60, } export const apps2 = { @@ -2982,7 +3041,7 @@ export const bindings2 = { put: put15, } -export const get60 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2998,10 +3057,10 @@ export const get60 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings4 = { - get: get60, + get: get61, } -export const get61 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3017,7 +3076,7 @@ export const get61 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings4 = { - get: get61, + get: get62, } export const byPolicyId5 = { @@ -3030,7 +3089,7 @@ export const accessPolicies7 = { byPolicyId: byPolicyId5, } -export const get62 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3041,7 +3100,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) export const accessPolicy4 = { - get: get62, + get: get63, } export const datasets2 = { @@ -3066,7 +3125,7 @@ export const rbac = { workspace, } -export const get63 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3077,10 +3136,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentSummaryResponse) export const summary2 = { - get: get63, + get: get64, } -export const get64 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3091,10 +3150,10 @@ export const get64 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get64, + get: get65, } -export const post48 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3106,10 +3165,10 @@ export const post48 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post48, + post: post49, } -export const post49 = oc +export const post50 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3120,11 +3179,11 @@ export const post49 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) -export const delete13 = { - post: post49, +export const delete14 = { + post: post50, } -export const get65 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3135,11 +3194,11 @@ export const get65 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get66 = { - get: get65, +export const get67 = { + get: get66, } -export const get67 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3151,10 +3210,10 @@ export const get67 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get67, + get: get68, } -export const post50 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3166,10 +3225,10 @@ export const post50 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post50, + post: post51, } -export const post51 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3181,14 +3240,14 @@ export const post51 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post51, + post: post52, } export const test = { pre, } -export const get68 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3200,10 +3259,10 @@ export const get68 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get68, + get: get69, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3215,13 +3274,13 @@ export const post52 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post52, + post: post53, } export const api = { add, - delete: delete13, - get: get66, + delete: delete14, + get: get67, remote, schema, test, @@ -3229,7 +3288,7 @@ export const api = { update: update2, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3246,10 +3305,10 @@ export const post53 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post53, + post: post54, } -export const get69 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3266,10 +3325,10 @@ export const get69 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get69, + get: get70, } -export const get70 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3289,7 +3348,7 @@ export const get70 = oc ) export const byCredentialType = { - get: get70, + get: get71, } export const schema2 = { @@ -3301,7 +3360,7 @@ export const credential = { schema: schema2, } -export const get71 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3318,10 +3377,10 @@ export const get71 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get71, + get: get72, } -export const post54 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3338,10 +3397,10 @@ export const post54 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post54, + post: post55, } -export const post55 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3357,11 +3416,11 @@ export const post55 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete14 = { - post: post55, +export const delete15 = { + post: post56, } -export const get72 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3373,10 +3432,10 @@ export const get72 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get72, + get: get73, } -export const get73 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3388,10 +3447,10 @@ export const get73 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get73, + get: get74, } -export const get74 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3405,10 +3464,10 @@ export const get74 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get74, + get: get75, } -export const delete15 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3423,7 +3482,7 @@ export const delete15 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get75 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3436,7 +3495,7 @@ export const get75 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post56 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3453,9 +3512,9 @@ export const post56 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete15, - get: get75, - post: post56, + delete: delete16, + get: get76, + post: post57, } export const oauth = { @@ -3463,7 +3522,7 @@ export const oauth = { customClient, } -export const get76 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3475,10 +3534,10 @@ export const get76 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get76, + get: get77, } -export const post57 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3495,7 +3554,7 @@ export const post57 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post57, + post: post58, } export const byProvider2 = { @@ -3503,7 +3562,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete14, + delete: delete15, icon: icon2, info: info2, oauth, @@ -3515,7 +3574,7 @@ export const builtin = { byProvider: byProvider2, } -export const post58 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3527,10 +3586,10 @@ export const post58 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post58, + post: post59, } -export const get77 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3542,14 +3601,14 @@ export const get77 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get77, + get: get78, } export const tools3 = { byProviderId, } -export const get78 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3561,14 +3620,14 @@ export const get78 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get78, + get: get79, } export const update4 = { byProviderId: byProviderId2, } -export const delete16 = oc +export const delete17 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3579,7 +3638,7 @@ export const delete16 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post59 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3602,15 +3661,15 @@ export const put16 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete16, - post: post59, + delete: delete17, + post: post60, put: put16, auth, tools: tools3, update: update4, } -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3622,10 +3681,10 @@ export const post60 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post60, + post: post61, } -export const post61 = oc +export const post62 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3636,11 +3695,11 @@ export const post61 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete17 = { - post: post61, +export const delete18 = { + post: post62, } -export const get79 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3651,11 +3710,11 @@ export const get79 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get80 = { - get: get79, +export const get81 = { + get: get80, } -export const get81 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3667,10 +3726,10 @@ export const get81 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get81, + get: get82, } -export const post62 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3682,13 +3741,13 @@ export const post62 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post62, + post: post63, } export const workflow = { create: create2, - delete: delete17, - get: get80, + delete: delete18, + get: get81, tools: tools4, update: update5, } @@ -3700,7 +3759,7 @@ export const toolProvider = { workflow, } -export const get82 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3712,10 +3771,10 @@ export const get82 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get82, + get: get83, } -export const get83 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3726,10 +3785,10 @@ export const get83 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get83, + get: get84, } -export const get84 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3740,10 +3799,10 @@ export const get84 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get84, + get: get85, } -export const get85 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3754,10 +3813,10 @@ export const get85 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get85, + get: get86, } -export const get86 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3768,7 +3827,7 @@ export const get86 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get86, + get: get87, } export const tools5 = { @@ -3778,7 +3837,7 @@ export const tools5 = { workflow: workflow2, } -export const get87 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3790,13 +3849,13 @@ export const get87 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get87, + get: get88, } /** * Get info for a trigger provider */ -export const get88 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3809,13 +3868,13 @@ export const get88 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get88, + get: get89, } /** * Remove custom OAuth client configuration */ -export const delete18 = oc +export const delete19 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3830,7 +3889,7 @@ export const delete18 = oc /** * Get OAuth client configuration for a provider */ -export const get89 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3845,7 +3904,7 @@ export const get89 = oc /** * Configure custom OAuth client for a provider */ -export const post63 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3863,9 +3922,9 @@ export const post63 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete18, - get: get89, - post: post63, + delete: delete19, + get: get90, + post: post64, } export const oauth2 = { @@ -3875,7 +3934,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post64 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3897,7 +3956,7 @@ export const post64 = oc ) export const bySubscriptionBuilderId = { - post: post64, + post: post65, } export const build = { @@ -3907,7 +3966,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post65 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3925,13 +3984,13 @@ export const post65 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post65, + post: post66, } /** * Get the request logs for a subscription instance for a trigger provider */ -export const get90 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3952,7 +4011,7 @@ export const get90 = oc ) export const bySubscriptionBuilderId2 = { - get: get90, + get: get91, } export const logs = { @@ -3962,7 +4021,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post66 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3984,7 +4043,7 @@ export const post66 = oc ) export const bySubscriptionBuilderId3 = { - post: post66, + post: post67, } export const update6 = { @@ -3994,7 +4053,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post67 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4016,7 +4075,7 @@ export const post67 = oc ) export const bySubscriptionBuilderId4 = { - post: post67, + post: post68, } export const verifyAndUpdate = { @@ -4026,7 +4085,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get91 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4047,7 +4106,7 @@ export const get91 = oc ) export const bySubscriptionBuilderId5 = { - get: get91, + get: get92, } export const builder = { @@ -4062,7 +4121,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get92 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4075,13 +4134,13 @@ export const get92 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get92, + get: get93, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get93 = oc +export const get94 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4098,7 +4157,7 @@ export const get93 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get93, + get: get94, } export const oauth3 = { @@ -4108,7 +4167,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post68 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4130,7 +4189,7 @@ export const post68 = oc ) export const bySubscriptionId = { - post: post68, + post: post69, } export const verify = { @@ -4154,7 +4213,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post69 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4170,14 +4229,14 @@ export const post69 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete19 = { - post: post69, +export const delete20 = { + post: post70, } /** * Update a subscription instance */ -export const post70 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4195,11 +4254,11 @@ export const post70 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post70, + post: post71, } export const subscriptions2 = { - delete: delete19, + delete: delete20, update: update7, } @@ -4215,7 +4274,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get94 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4227,7 +4286,7 @@ export const get94 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get94, + get: get95, } export const current = { @@ -4240,7 +4299,7 @@ export const current = { members, modelProviders, models: models2, - networkAccessPolicy, + networkAccessGroups, permission, plugin: plugin2, rbac, @@ -4253,7 +4312,7 @@ export const current = { triggers, } -export const post71 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4266,14 +4325,14 @@ export const post71 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload2 = { - post: post71, + post: post72, } export const webappLogo = { upload: upload2, } -export const get95 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4283,7 +4342,7 @@ export const get95 = oc }) .output(zGetWorkspacesCustomConfigResponse) -export const post72 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4295,12 +4354,12 @@ export const post72 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - get: get95, - post: post72, + get: get96, + post: post73, webappLogo, } -export const post73 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4312,10 +4371,10 @@ export const post73 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post73, + post: post74, } -export const post74 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4327,10 +4386,10 @@ export const post74 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post74, + post: post75, } -export const get96 = oc +export const get97 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4342,7 +4401,7 @@ export const get96 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get96, + get: get97, } export const byIconType = { @@ -4361,7 +4420,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get97 = oc +export const get98 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4372,7 +4431,7 @@ export const get97 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get97, + get: get98, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 417061a7caffb8..0922d0abc2d764 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -364,21 +364,33 @@ export type AvailableModelListResponse = { data: Array } -export type NetworkAccessPolicyListResponse = { +export type NetworkAccessGroupListResponse = { entitled: boolean - policies: Array + groups: Array tenant_id: string } -export type NetworkAccessPolicyPayload = { +export type NetworkAccessGroupCreatePayload = { allowed_cidrs: Array - expected_version: number + description?: string mode: 'disabled' | 'enforce' | 'shadow' - scope: 'mcp' | 'plugin_trigger' | 'service_api' | 'workflow_webhook' + name: string +} + +export type NetworkAccessGroupMutationResponse = { + group: NetworkAccessGroupResponse +} + +export type NetworkAccessGroupDeleteResponse = { + deleted: boolean } -export type NetworkAccessPolicyUpdateResponse = { - policy: NetworkAccessPolicyResponse +export type NetworkAccessGroupUpdatePayload = { + allowed_cidrs: Array + description?: string + expected_version: number + mode: 'disabled' | 'enforce' | 'shadow' + name: string } export type WorkspacePermissionResponse = { @@ -1306,12 +1318,15 @@ export type ProviderWithModelsResponse = { tenant_id: string } -export type NetworkAccessPolicyResponse = { - allowed_cidrs: Array - created_at?: string | null +export type NetworkAccessGroupResponse = { + allowed_cidrs?: Array + created_at: string + description?: string + id: string mode: 'disabled' | 'enforce' | 'shadow' - scope: 'mcp' | 'plugin_trigger' | 'service_api' | 'workflow_webhook' - updated_at?: string | null + name: string + tenant_id: string + updated_at: string updated_by_account_id?: string | null version: number } @@ -3526,33 +3541,83 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse = GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] -export type GetWorkspacesCurrentNetworkAccessPolicyData = { +export type GetWorkspacesCurrentNetworkAccessGroupsData = { body?: never path?: never query?: never - url: '/workspaces/current/network-access-policy' + url: '/workspaces/current/network-access-groups' } -export type GetWorkspacesCurrentNetworkAccessPolicyResponses = { - 200: NetworkAccessPolicyListResponse +export type GetWorkspacesCurrentNetworkAccessGroupsResponses = { + 200: NetworkAccessGroupListResponse } -export type GetWorkspacesCurrentNetworkAccessPolicyResponse = - GetWorkspacesCurrentNetworkAccessPolicyResponses[keyof GetWorkspacesCurrentNetworkAccessPolicyResponses] +export type GetWorkspacesCurrentNetworkAccessGroupsResponse = + GetWorkspacesCurrentNetworkAccessGroupsResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsResponses] -export type PutWorkspacesCurrentNetworkAccessPolicyData = { - body: NetworkAccessPolicyPayload +export type PostWorkspacesCurrentNetworkAccessGroupsData = { + body: NetworkAccessGroupCreatePayload path?: never query?: never - url: '/workspaces/current/network-access-policy' + url: '/workspaces/current/network-access-groups' +} + +export type PostWorkspacesCurrentNetworkAccessGroupsResponses = { + 201: NetworkAccessGroupMutationResponse +} + +export type PostWorkspacesCurrentNetworkAccessGroupsResponse = + PostWorkspacesCurrentNetworkAccessGroupsResponses[keyof PostWorkspacesCurrentNetworkAccessGroupsResponses] + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body?: never + path: { + group_id: string + } + query: { + expected_version: number + } + url: '/workspaces/current/network-access-groups/{group_id}' +} + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupDeleteResponse +} + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body?: never + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupMutationResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body: NetworkAccessGroupUpdatePayload + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}' } -export type PutWorkspacesCurrentNetworkAccessPolicyResponses = { - 200: NetworkAccessPolicyUpdateResponse +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupMutationResponse } -export type PutWorkspacesCurrentNetworkAccessPolicyResponse = - PutWorkspacesCurrentNetworkAccessPolicyResponses[keyof PutWorkspacesCurrentNetworkAccessPolicyResponses] +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] export type GetWorkspacesCurrentPermissionData = { body?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 7f4c1e7f230364..0b2a2411b17988 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -248,13 +248,31 @@ export const zParserPreferredProviderType = z.object({ }) /** - * NetworkAccessPolicyPayload + * NetworkAccessGroupCreatePayload */ -export const zNetworkAccessPolicyPayload = z.object({ +export const zNetworkAccessGroupCreatePayload = z.object({ allowed_cidrs: z.array(z.string()).max(100), - expected_version: z.int().gte(0), + description: z.string().max(500).optional().default(''), mode: z.enum(['disabled', 'enforce', 'shadow']), - scope: z.enum(['mcp', 'plugin_trigger', 'service_api', 'workflow_webhook']), + name: z.string().min(1).max(100), +}) + +/** + * NetworkAccessGroupDeleteResponse + */ +export const zNetworkAccessGroupDeleteResponse = z.object({ + deleted: z.boolean(), +}) + +/** + * NetworkAccessGroupUpdatePayload + */ +export const zNetworkAccessGroupUpdatePayload = z.object({ + allowed_cidrs: z.array(z.string()).max(100), + description: z.string().max(500).optional().default(''), + expected_version: z.int().gte(1), + mode: z.enum(['disabled', 'enforce', 'shadow']), + name: z.string().min(1).max(100), }) /** @@ -996,32 +1014,35 @@ export const zCredentialConfiguration = z.object({ }) /** - * NetworkAccessPolicyResponse + * NetworkAccessGroupResponse */ -export const zNetworkAccessPolicyResponse = z.object({ - allowed_cidrs: z.array(z.string()), - created_at: z.iso.datetime().nullish(), +export const zNetworkAccessGroupResponse = z.object({ + allowed_cidrs: z.array(z.string()).optional(), + created_at: z.iso.datetime(), + description: z.string().optional().default(''), + id: z.string(), mode: z.enum(['disabled', 'enforce', 'shadow']), - scope: z.enum(['mcp', 'plugin_trigger', 'service_api', 'workflow_webhook']), - updated_at: z.iso.datetime().nullish(), + name: z.string(), + tenant_id: z.string(), + updated_at: z.iso.datetime(), updated_by_account_id: z.string().nullish(), - version: z.int().gte(0), + version: z.int().gte(1), }) /** - * NetworkAccessPolicyListResponse + * NetworkAccessGroupListResponse */ -export const zNetworkAccessPolicyListResponse = z.object({ +export const zNetworkAccessGroupListResponse = z.object({ entitled: z.boolean(), - policies: z.array(zNetworkAccessPolicyResponse), + groups: z.array(zNetworkAccessGroupResponse), tenant_id: z.string(), }) /** - * NetworkAccessPolicyUpdateResponse + * NetworkAccessGroupMutationResponse */ -export const zNetworkAccessPolicyUpdateResponse = z.object({ - policy: zNetworkAccessPolicyResponse, +export const zNetworkAccessGroupMutationResponse = z.object({ + group: zNetworkAccessGroupResponse, }) /** @@ -4141,16 +4162,53 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = zAvailableModelListResponse /** - * Workspace network access policies retrieved successfully + * Workspace network access groups retrieved successfully + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsResponse = zNetworkAccessGroupListResponse + +export const zPostWorkspacesCurrentNetworkAccessGroupsBody = zNetworkAccessGroupCreatePayload + +/** + * Workspace network access group created successfully */ -export const zGetWorkspacesCurrentNetworkAccessPolicyResponse = zNetworkAccessPolicyListResponse +export const zPostWorkspacesCurrentNetworkAccessGroupsResponse = zNetworkAccessGroupMutationResponse + +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) -export const zPutWorkspacesCurrentNetworkAccessPolicyBody = zNetworkAccessPolicyPayload +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery = z.object({ + expected_version: z.int().gte(1), +}) + +/** + * Workspace network access group deleted successfully + */ +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupDeleteResponse + +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) + +/** + * Workspace network access group retrieved successfully + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupMutationResponse + +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody = + zNetworkAccessGroupUpdatePayload + +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) /** - * Workspace network access policy updated successfully + * Workspace network access group updated successfully */ -export const zPutWorkspacesCurrentNetworkAccessPolicyResponse = zNetworkAccessPolicyUpdateResponse +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupMutationResponse /** * Success From 5fad259ed1421815bbd836cccffeee40ddec18a4 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 21 Aug 2026 11:47:08 +0800 Subject: [PATCH 005/108] test: satisfy strict network access checks --- .../controllers/console/workspace/test_network_access_group.py | 1 + .../services/test_network_access_group_billing_service.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 03842355b360ee..bf54a5dc978d6a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -296,6 +296,7 @@ def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPE def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, expected_message: str) -> None: error = _translate_upstream_error(NetworkAccessGroupUpstreamError(409, reason)) assert isinstance(error, Conflict) + assert error.description is not None assert expected_message in error.description diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py index e71e8e98916453..39d9c3ba0bf53b 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -26,7 +26,7 @@ def billing_config() -> Iterator[None]: def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None: response = MagicMock(status_code=httpx.codes.OK) response.json.side_effect = [ - {"tenant_id": TENANT_ID, "entitled": True, "groups": []}, + {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}, {"group": {"id": GROUP_ID}}, ] From 9347c0750ddc111cf5a85043db7ff84b90f74e35 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Mon, 24 Aug 2026 13:07:51 +0800 Subject: [PATCH 006/108] fix: clean app network binding after deletion --- api/services/billing_service.py | 14 +++ api/tasks/remove_app_and_related_data_task.py | 45 +++++++ ...st_network_access_group_billing_service.py | 21 +++- .../test_remove_app_and_related_data_task.py | 117 ++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 0b0359a53d3582..ffc9103487741b 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -562,6 +562,20 @@ def update_app_network_access_group( }, ) + @classmethod + def cleanup_app_network_access_group_binding(cls, tenant_id: str, app_id: str) -> dict[str, Any]: + """Remove an App binding through the internal App lifecycle endpoint. + + This must only be called by the post-commit App deletion task. Account + or workspace-member deletion does not own App bindings and must not use + this operation. + """ + + return cls._send_network_access_group_request( + "DELETE", + f"/tenants/{tenant_id}/apps/{app_id}/network-access-group-binding", + ) + @classmethod def _send_network_access_group_request( cls, diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index f8af48e09908e3..52212131124fda 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -52,15 +52,60 @@ ) from repositories.factory import DifyAPIRepositoryFactory from services.api_token_service import ApiTokenCache +from services.billing_service import BillingService logger = logging.getLogger(__name__) +NETWORK_ACCESS_BINDING_CLEANUP_DELAY_SECONDS = 10 +NETWORK_ACCESS_BINDING_CLEANUP_RETRY_BASE_SECONDS = 15 +NETWORK_ACCESS_BINDING_CLEANUP_RETRY_MAX_SECONDS = 10 * 60 + + +def _network_access_binding_cleanup_retry_delay(retries: int) -> int: + exponent = min(max(retries, 0), 6) + return min( + NETWORK_ACCESS_BINDING_CLEANUP_RETRY_BASE_SECONDS * (2**exponent), + NETWORK_ACCESS_BINDING_CLEANUP_RETRY_MAX_SECONDS, + ) + + +@shared_task(queue="app_deletion", bind=True, max_retries=24) +def cleanup_app_network_access_group_binding_task(self, tenant_id: str, app_id: str) -> None: + """Reliably remove a SaaS binding after its owning App was committed deleted.""" + + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + return + try: + BillingService.cleanup_app_network_access_group_binding(tenant_id, app_id) + except Exception as e: + countdown = _network_access_binding_cleanup_retry_delay(self.request.retries) + logger.exception( + "Failed to clean up App network access group binding", + extra={"tenant_id": tenant_id, "app_id": app_id, "retry_countdown": countdown}, + ) + raise self.retry(exc=e, countdown=countdown) + + +def _schedule_app_network_access_group_binding_cleanup(tenant_id: str, app_id: str) -> None: + """Schedule only from App lifecycle cleanup, never account/member deletion.""" + + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + return + cleanup_app_network_access_group_binding_task.apply_async( + kwargs={"tenant_id": tenant_id, "app_id": app_id}, + countdown=NETWORK_ACCESS_BINDING_CLEANUP_DELAY_SECONDS, + ) + @shared_task(queue="app_deletion", bind=True, max_retries=3) def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green")) start_at = time.perf_counter() try: + # AppService publishes this task after the App transaction commits. + # Schedule the independent high-retry SaaS cleanup before legacy local + # cleanup so a later local failure cannot strand the binding. + _schedule_app_network_access_group_binding_cleanup(tenant_id, app_id) # Delete related data _delete_app_model_configs(tenant_id, app_id) _delete_app_site(tenant_id, app_id) diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py index 39d9c3ba0bf53b..71c429ec2ed8ab 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -187,7 +187,26 @@ def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: ] -@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500]) +@pytest.mark.usefixtures("billing_config") +def test_app_lifecycle_cleanup_uses_internal_secret_endpoint() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = {"deleted": True} + + with patch("services.billing_service._http_client.request", return_value=response) as request: + result = BillingService.cleanup_app_network_access_group_binding(TENANT_ID, APP_ID) + + assert result == {"deleted": True} + request.assert_called_once_with( + "DELETE", + f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group-binding", + json=None, + params=None, + headers=HEADERS, + follow_redirects=True, + ) + + +@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500, 503]) def test_network_access_group_request_preserves_upstream_status(status_code: int) -> None: response = MagicMock(status_code=status_code) with ( diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 125e447a10e685..5f891b891e3c7c 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -14,6 +14,7 @@ from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowArchiveLog +from services.billing_service import NetworkAccessGroupUpstreamError from tasks.remove_app_and_related_data_task import ( _delete_app_stars, _delete_app_workflow_archive_logs, @@ -118,6 +119,122 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest delete_workflows.assert_called_once_with("tenant-1", "app-1") +def test_cloud_app_cleanup_schedules_independent_binding_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: + apply_async = MagicMock() + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + apply_async, + ) + + remove_app_task_module._schedule_app_network_access_group_binding_cleanup("tenant-1", "app-1") + + apply_async.assert_called_once_with( + kwargs={"tenant_id": "tenant-1", "app_id": "app-1"}, + countdown=10, + ) + + +def test_community_app_cleanup_does_not_schedule_binding_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: + apply_async = MagicMock() + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + monkeypatch.setattr( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + apply_async, + ) + + remove_app_task_module._schedule_app_network_access_group_binding_cleanup("tenant-1", "app-1") + + apply_async.assert_not_called() + + +def test_independent_binding_cleanup_task_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + cleanup = MagicMock(return_value={"deleted": True}) + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr( + remove_app_task_module.BillingService, + "cleanup_app_network_access_group_binding", + cleanup, + ) + + remove_app_task_module.cleanup_app_network_access_group_binding_task.run(tenant_id="tenant-1", app_id="app-1") + + cleanup.assert_called_once_with("tenant-1", "app-1") + assert remove_app_task_module.cleanup_app_network_access_group_binding_task.max_retries == 24 + + +def test_independent_binding_cleanup_task_retries_with_exponential_backoff( + monkeypatch: pytest.MonkeyPatch, +) -> None: + upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_APP_STILL_EXISTS") + retry_error = RuntimeError("retry scheduled") + retry = MagicMock(side_effect=retry_error) + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr( + remove_app_task_module.BillingService, + "cleanup_app_network_access_group_binding", + MagicMock(side_effect=upstream_error), + ) + monkeypatch.setattr(remove_app_task_module.cleanup_app_network_access_group_binding_task, "retry", retry) + + with pytest.raises(RuntimeError, match="retry scheduled"): + remove_app_task_module.cleanup_app_network_access_group_binding_task.run(tenant_id="tenant-1", app_id="app-1") + + retry.assert_called_once_with(exc=upstream_error, countdown=15) + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(5) == 480 + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(6) == 600 + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(24) == 600 + + +def test_parent_local_cleanup_failure_happens_after_binding_cleanup_is_scheduled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + local_error = RuntimeError("local cleanup failed") + retry_error = RuntimeError("parent retry scheduled") + apply_async = MagicMock() + retry = MagicMock(side_effect=retry_error) + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + apply_async, + ) + monkeypatch.setattr(remove_app_task_module, "_delete_app_model_configs", MagicMock(side_effect=local_error)) + monkeypatch.setattr(remove_app_task_module.remove_app_and_related_data_task, "retry", retry) + + with pytest.raises(RuntimeError, match="parent retry scheduled"): + remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1") + + apply_async.assert_called_once_with( + kwargs={"tenant_id": "tenant-1", "app_id": "app-1"}, + countdown=10, + ) + retry.assert_called_once_with(exc=local_error, countdown=60) + + +def test_parent_retries_before_local_cleanup_when_binding_schedule_fails(monkeypatch: pytest.MonkeyPatch) -> None: + broker_error = RuntimeError("broker unavailable") + retry_error = RuntimeError("parent retry scheduled") + retry = MagicMock(side_effect=retry_error) + first_local_cleanup = MagicMock() + monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + MagicMock(side_effect=broker_error), + ) + monkeypatch.setattr(remove_app_task_module, "_delete_app_model_configs", first_local_cleanup) + monkeypatch.setattr(remove_app_task_module.remove_app_and_related_data_task, "retry", retry) + + with pytest.raises(RuntimeError, match="parent retry scheduled"): + remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1") + + retry.assert_called_once_with(exc=broker_error, countdown=60) + first_local_cleanup.assert_not_called() + + class TestDeleteDraftVariablesBatch: def test_delete_draft_variables_batch_invalid_batch_size(self): """Test that invalid batch size raises ValueError.""" From e35de2d3aaa06f23faf9f2f8493555229222154b Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Mon, 24 Aug 2026 13:18:36 +0800 Subject: [PATCH 007/108] fix: align network access authorization with main --- .../console/workspace/network_access_group.py | 9 ++-- api/services/billing_service.py | 2 +- .../workspace/test_network_access_group.py | 46 ++++++------------- 3 files changed, 19 insertions(+), 38 deletions(-) diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index f22f6399f98292..39860e8cfa7524 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -17,11 +17,10 @@ with_current_tenant_id, with_current_user, ) -from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import dump_response from libs.login import login_required -from models import Account, App +from models import Account, App, TenantAccountRole from services.billing_service import BillingService, NetworkAccessGroupUpstreamError NetworkAccessGroupMode = Literal["disabled", "shadow", "enforce"] @@ -126,10 +125,8 @@ class AppNetworkAccessGroupMutationResponse(ResponseModel): def _ensure_workspace_admin_or_owner(current_user: Account) -> None: """Authorize management against the persisted current-workspace membership.""" - try: - BillingService.is_tenant_owner_or_admin(current_user, session=db.session()) - except ValueError as exc: - raise Forbidden("Only workspace owners and administrators can manage network access groups.") from exc + if not TenantAccountRole.is_privileged_role(current_user.current_role): + raise Forbidden("Only workspace owners and administrators can manage network access groups.") def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception: diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 83e3d4226697fe..c5b7314f4f2b16 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -2,7 +2,7 @@ import logging import os from collections.abc import Sequence -from typing import Literal, NotRequired, TypedDict +from typing import Any, Literal, NotRequired, TypedDict import httpx from pydantic import TypeAdapter, ValidationError diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index bf54a5dc978d6a..586a9e0f73671b 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -18,6 +18,7 @@ NetworkAccessGroupUpdatePayload, _translate_upstream_error, ) +from models import TenantAccountRole from services.billing_service import BillingService, NetworkAccessGroupUpstreamError TENANT_ID = "11111111-1111-4111-8111-111111111111" @@ -27,6 +28,10 @@ BINDING_ID = "55555555-5555-4555-8555-555555555555" +def _current_user(role: TenantAccountRole = TenantAccountRole.OWNER) -> SimpleNamespace: + return SimpleNamespace(id=ACCOUNT_ID, current_role=role) + + def _group_payload() -> dict[str, object]: return { "id": GROUP_ID, @@ -59,17 +64,14 @@ def _binding_payload(*, group_id: str | None = GROUP_ID) -> dict[str, object]: def test_list_authorizes_and_forwards_tenant_and_actor() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() upstream_payload = {"tenantId": TENANT_ID, "entitled": True, "groups": [_group_payload()]} with ( - patch("controllers.console.workspace.network_access_group.db") as db, - patch.object(BillingService, "is_tenant_owner_or_admin") as authorize, patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload) as list_groups, ): result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) - authorize.assert_called_once_with(current_user, session=db.session()) list_groups.assert_called_once_with(TENANT_ID, ACCOUNT_ID) assert result["tenant_id"] == TENANT_ID assert result["groups"][0]["name"] == "Office network" @@ -80,7 +82,7 @@ def test_list_authorizes_and_forwards_tenant_and_actor() -> None: def test_create_injects_actor_and_returns_created_contract() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.post) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() request_payload = NetworkAccessGroupCreatePayload( name="Office network", description="Reusable office egress addresses", @@ -89,8 +91,6 @@ def test_create_injects_actor_and_returns_created_contract() -> None: ) with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "create_network_access_group", @@ -119,7 +119,7 @@ def test_create_injects_actor_and_returns_created_contract() -> None: def test_update_group_forwards_path_id_and_expected_version() -> None: api = CurrentWorkspaceNetworkAccessGroupApi() method = unwrap(api.put) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() request_payload = NetworkAccessGroupUpdatePayload( name="Office network", description="Updated", @@ -129,8 +129,6 @@ def test_update_group_forwards_path_id_and_expected_version() -> None: ) with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "update_network_access_group", @@ -161,11 +159,9 @@ def test_update_group_forwards_path_id_and_expected_version() -> None: def test_delete_group_reads_expected_version_from_query_and_injects_actor() -> None: api = CurrentWorkspaceNetworkAccessGroupApi() method = unwrap(api.delete) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "delete_network_access_group", @@ -192,13 +188,11 @@ def test_delete_group_reads_expected_version_from_query_and_injects_actor() -> N def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.get) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True, "binding": None} with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "get_app_network_access_group", @@ -220,13 +214,11 @@ def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> N def test_app_put_assigns_or_unassigns_group(group_id: str | None) -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.put) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) request_payload = AppNetworkAccessGroupUpdatePayload(group_id=group_id, expected_version=2) with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "update_app_network_access_group", @@ -255,11 +247,9 @@ def test_app_put_assigns_or_unassigns_group(group_id: str | None) -> None: def test_list_rejects_non_privileged_workspace_member_before_upstream_call() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user(TenantAccountRole.NORMAL) with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin", side_effect=ValueError("not privileged")), patch.object(BillingService, "list_network_access_groups") as list_groups, pytest.raises(Forbidden), ): @@ -308,11 +298,9 @@ def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "list_network_access_groups", @@ -326,14 +314,12 @@ def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None: def test_response_contract_accepts_protojson_omitted_empty_group_fields() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() group = _group_payload() group.pop("description") group.pop("allowedCidrs") with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "list_network_access_groups", @@ -349,15 +335,13 @@ def test_response_contract_accepts_protojson_omitted_empty_group_fields() -> Non def test_binding_response_accepts_protojson_omitted_null_group_id() -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.put) - current_user = SimpleNamespace(id=ACCOUNT_ID) + current_user = _current_user() app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) request_payload = AppNetworkAccessGroupUpdatePayload(group_id=None, expected_version=2) binding = _binding_payload(group_id=None) binding.pop("groupId") with ( - patch("controllers.console.workspace.network_access_group.db"), - patch.object(BillingService, "is_tenant_owner_or_admin"), patch.object( BillingService, "update_app_network_access_group", From c015442548943386f0e32065d6807bf8f3fdeabc Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 8 Sep 2026 13:31:04 +0800 Subject: [PATCH 008/108] feat(web): add Access Control paywall entry on Access Point Show a PRO chip and upgrade popover for Cloud sandbox workspaces on the Access Point page. --- .../app/access-point/__tests__/index.spec.tsx | 10 ++ .../access-control/__tests__/index.spec.tsx | 129 ++++++++++++++++++ .../access-control/free-paywall.tsx | 52 +++++++ .../app/access-point/access-control/index.tsx | 58 ++++++++ .../access-control/restricted-preview.tsx | 43 ++++++ web/app/components/app/access-point/index.tsx | 4 +- web/i18n/ar-TN/deployments.json | 7 + web/i18n/de-DE/deployments.json | 7 + web/i18n/en-US/deployments.json | 7 + web/i18n/es-ES/deployments.json | 7 + web/i18n/fa-IR/deployments.json | 7 + web/i18n/fr-FR/deployments.json | 7 + web/i18n/hi-IN/deployments.json | 7 + web/i18n/id-ID/deployments.json | 7 + web/i18n/it-IT/deployments.json | 7 + web/i18n/ja-JP/deployments.json | 7 + web/i18n/ko-KR/deployments.json | 7 + web/i18n/lo-LA/deployments.json | 7 + web/i18n/nl-NL/deployments.json | 7 + web/i18n/pl-PL/deployments.json | 7 + web/i18n/pt-BR/deployments.json | 7 + web/i18n/ro-RO/deployments.json | 7 + web/i18n/ru-RU/deployments.json | 7 + web/i18n/sl-SI/deployments.json | 7 + web/i18n/th-TH/deployments.json | 7 + web/i18n/tr-TR/deployments.json | 7 + web/i18n/uk-UA/deployments.json | 7 + web/i18n/vi-VN/deployments.json | 7 + web/i18n/zh-Hans/deployments.json | 7 + web/i18n/zh-Hant/deployments.json | 7 + 30 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 web/app/components/app/access-point/access-control/__tests__/index.spec.tsx create mode 100644 web/app/components/app/access-point/access-control/free-paywall.tsx create mode 100644 web/app/components/app/access-point/access-control/index.tsx create mode 100644 web/app/components/app/access-point/access-control/restricted-preview.tsx diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index ee234efb99f4ce..e4abe18a30a5da 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { render } from '@/test/console/render' import { createTestQueryClient } from '@/test/query-client' @@ -107,6 +108,7 @@ const renderAccessPoint = ({ } = {}) => { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) + seedSystemFeatures(queryClient) const queryOptions = consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ input: { @@ -139,6 +141,14 @@ describe('AccessPoint', () => { appPermissionKeys = [AppACLPermission.AccessPointView] }) + it('does not show Access Control on community edition', () => { + renderAccessPoint() + + expect( + screen.queryByRole('button', { name: /Access Control|studio\.accessControl\.entryLabel/ }), + ).not.toBeInTheDocument() + }) + it('renders Built-in and only in-use environments from the API', () => { renderAccessPoint() diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx new file mode 100644 index 00000000000000..197c6ebf07bc42 --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -0,0 +1,129 @@ +import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useModalContext } from '@/context/modal-context' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { AccessControlEntry } from '..' + +const mockSetShowPricingModal = vi.fn() +const accessControlTranslations = vi.hoisted(() => ({ + 'studio.accessControl.entryLabel': 'Access Control', + 'studio.accessControl.paywallDescription': 'Restrict this app to IP addresses you trust.', + 'studio.accessControl.paywallTitle': 'Access Control', + 'studio.accessControl.previewAppName': 'Code Companion', + 'studio.accessControl.previewCaption': + "This app is only available on your organization's network.", + 'studio.accessControl.proBadge': 'PRO', + 'studio.accessControl.turnOn': 'Turn on Access Control', +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: vi.fn(), + useModalContextSelector: vi.fn(), +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock(accessControlTranslations) +}) + +const renderEntry = ({ + plan, + deploymentEdition = 'CLOUD', +}: { + plan?: CloudPlan + deploymentEdition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' +} = {}) => { + return renderWithConsoleQuery(, { + systemFeatures: { deployment_edition: deploymentEdition }, + features: plan + ? { + billing: { + subscription: { interval: 'month', plan }, + }, + } + : undefined, + }) +} + +const getChip = () => screen.getByRole('button', { name: /Access Control/ }) + +describe('AccessControlEntry', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useModalContext).mockReturnValue({ + hasBlockingModalOpen: false, + setShowModerationSettingModal: vi.fn(), + setShowExternalDataToolModal: vi.fn(), + setShowPricingModal: mockSetShowPricingModal, + setShowAnnotationFullModal: vi.fn(), + setShowModelModal: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + setShowOpeningModal: vi.fn(), + setShowUpdatePluginModal: vi.fn(), + }) + }) + + it('does not render on community edition', () => { + renderEntry({ deploymentEdition: 'COMMUNITY', plan: 'sandbox' }) + + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + }) + + it('does not render on enterprise edition', () => { + renderEntry({ deploymentEdition: 'ENTERPRISE', plan: 'sandbox' }) + + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + }) + + it.each(['professional', 'team'] as const)('does not render on Cloud %s', (plan) => { + renderEntry({ plan }) + + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + }) + + it('renders a single chip with a non-interactive PRO badge for Cloud sandbox', () => { + renderEntry({ plan: 'sandbox' }) + + const chip = getChip() + expect(chip).toBeInTheDocument() + expect(within(chip).getByText('PRO')).toBeInTheDocument() + expect(within(chip).queryByRole('button')).not.toBeInTheDocument() + }) + + it('opens the paywall popover from the chip and sends the user to pricing', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'sandbox' }) + + await user.click(getChip()) + + expect(screen.getByText('Restrict this app to IP addresses you trust.')).toBeInTheDocument() + expect( + screen.getByText("This app is only available on your organization's network."), + ).toBeInTheDocument() + expect(screen.getAllByText('PRO')).toHaveLength(2) + + const turnOn = screen.getByRole('button', { name: 'Turn on Access Control' }) + expect(turnOn).not.toHaveTextContent('PRO') + await user.click(turnOn) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('closes the paywall on Escape without side effects', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'sandbox' }) + + await user.click(getChip()) + expect(screen.getByText('Restrict this app to IP addresses you trust.')).toBeInTheDocument() + + await user.keyboard('{Escape}') + + await waitFor(() => { + expect( + screen.queryByText('Restrict this app to IP addresses you trust.'), + ).not.toBeInTheDocument() + }) + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/access-point/access-control/free-paywall.tsx b/web/app/components/app/access-point/access-control/free-paywall.tsx new file mode 100644 index 00000000000000..e60de9d6a6891f --- /dev/null +++ b/web/app/components/app/access-point/access-control/free-paywall.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { PopoverDescription, PopoverTitle } from '@langgenius/dify-ui/popover' +import { useTranslation } from 'react-i18next' +import { AccessControlRestrictedPreview } from './restricted-preview' + +type AccessControlFreePaywallProps = { + onTurnOn: () => void +} + +export function AccessControlFreePaywall({ onTurnOn }: AccessControlFreePaywallProps) { + const { t } = useTranslation() + const title = t(($) => $['studio.accessControl.paywallTitle'], { ns: 'deployments' }) + const turnOn = t(($) => $['studio.accessControl.turnOn'], { ns: 'deployments' }) + const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) + + return ( +
+
+
+ + {title} + + + {t(($) => $['studio.accessControl.paywallDescription'], { ns: 'deployments' })} + +
+ +
+
+
+ + + + {pro} + + +
+
+
+ ) +} diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx new file mode 100644 index 00000000000000..e7904bb096214b --- /dev/null +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -0,0 +1,58 @@ +'use client' + +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import PremiumBadge from '@/app/components/base/premium-badge' +import { useModalContext } from '@/context/modal-context' +import { deploymentEditionAtom } from '@/features/system-features/state' +import { consoleQuery } from '@/service/console' +import { AccessControlFreePaywall } from './free-paywall' + +type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void + +export function AccessControlEntry() { + const { t } = useTranslation() + const deploymentEdition = useAtomValue(deploymentEditionAtom) + const { data: plan } = useQuery( + consoleQuery.features.get.queryOptions({ + enabled: deploymentEdition === 'CLOUD', + select: (data) => data.billing.subscription.plan, + }), + ) + const { setShowPricingModal } = useModalContext() + + if (deploymentEdition !== 'CLOUD' || plan !== 'sandbox') return null + + const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) + const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) + + const handleTurnOn = () => { + setShowPricingModal() + const gtag = (window as Window & { gtag?: GtagHandler }).gtag + if (gtag) gtag('event', 'click_upgrade_btn', { loc: 'access-control-paywall' }) + } + + return ( + + + + {label} + + + {pro} + + + + + + + ) +} diff --git a/web/app/components/app/access-point/access-control/restricted-preview.tsx b/web/app/components/app/access-point/access-control/restricted-preview.tsx new file mode 100644 index 00000000000000..c830c1802ce008 --- /dev/null +++ b/web/app/components/app/access-point/access-control/restricted-preview.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useTranslation } from 'react-i18next' + +export function AccessControlRestrictedPreview() { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index cda649d7c584e8..7a693f094db03a 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -18,6 +18,7 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' +import { AccessControlEntry } from './access-control' import { BuiltInAccessPoints } from './built-in-access-points' import { DeployedEnvironmentAccessPoints } from './deployed-environment-access-points' import { @@ -70,10 +71,11 @@ function AccessPointContent({
-
+

{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}

+

{t(($) => $['studio.accessPoint.description'], { ns: 'deployments' })} diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index 6951e5402b5a83..36f7828971125c 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "غير منشور", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "جاري إلغاء النشر", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "غير معروف", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "استدعِ التطبيق من خدماتك عبر HTTP", "studio.accessPoint.description": "جميع طرق الوصول إلى هذا التطبيق في البيئة المدمجة وكل بيئة منشورة.", "studio.accessPoint.embedIntoSite": "تضمين في الموقع", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index 5e8e411e2abc33..36cd854da8ca56 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nicht bereitgestellt", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Bereitstellung wird aufgehoben", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unbekannt", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Rufen Sie die App über HTTP aus Ihren Diensten auf", "studio.accessPoint.description": "Alle Möglichkeiten, diese App in der integrierten Umgebung und jeder bereitgestellten Umgebung zu erreichen.", "studio.accessPoint.embedIntoSite": "In Website einbetten", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index dbc046fb923a9f..add54fec723c72 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Not deployed", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Undeploying", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unknown", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Integrate the app into your services over HTTP", "studio.accessPoint.description": "All the ways this app can be reached. Access points match the running version only.", "studio.accessPoint.embedIntoSite": "Embed Into Site", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index c106c952abd0e8..af4ee71d553435 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "No desplegado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Retirando despliegue", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconocido", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Llama a la aplicación desde tus servicios a través de HTTP", "studio.accessPoint.description": "Todas las formas de acceder a esta aplicación en el entorno integrado y en cada entorno desplegado.", "studio.accessPoint.embedIntoSite": "Insertar en el sitio", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index a92a966c5ca2a6..f48f640dad2f98 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "مستقر نشده", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "در حال لغو استقرار", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ناشناخته", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "برنامه را از سرویس‌های خود از طریق HTTP فراخوانی کنید", "studio.accessPoint.description": "همه روش‌های دسترسی به این برنامه در محیط داخلی و هر محیط استقرار‌یافته.", "studio.accessPoint.embedIntoSite": "جاسازی در سایت", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index f8944a8329f9fe..13518732df2b36 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non déployé", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annulation du déploiement", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Inconnu", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Appelez l’application depuis vos services via HTTP", "studio.accessPoint.description": "Toutes les façons d’accéder à cette application dans l’environnement intégré et dans chaque environnement déployé.", "studio.accessPoint.embedIntoSite": "Intégrer au site", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index 2ee9198915fca9..38834822638125 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "डिप्लॉय नहीं किया गया", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "अनडिप्लॉय हो रहा है", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "अज्ञात", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "HTTP के ज़रिए अपनी सेवाओं से ऐप को कॉल करें", "studio.accessPoint.description": "बिल्ट-इन और हर डिप्लॉय किए गए एनवायरनमेंट में इस ऐप तक पहुँचने के सभी तरीके।", "studio.accessPoint.embedIntoSite": "साइट में एम्बेड करें", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index 7b1acc4817c688..d08ac1a0cad109 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Belum diterapkan", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Membatalkan penerapan", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Tidak diketahui", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Panggil aplikasi dari layanan Anda melalui HTTP", "studio.accessPoint.description": "Semua cara aplikasi ini dapat diakses di lingkungan Bawaan dan setiap lingkungan yang di-deploy.", "studio.accessPoint.embedIntoSite": "Sematkan ke situs", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index ce823dc8737d72..5c014d678adccf 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non distribuito", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annullamento distribuzione", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Sconosciuto", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chiama l’app dai tuoi servizi tramite HTTP", "studio.accessPoint.description": "Tutti i modi per raggiungere questa app nell’ambiente integrato e in ogni ambiente distribuito.", "studio.accessPoint.embedIntoSite": "Incorpora nel sito", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index 8d2c18aca2181b..58cb3dc645b2e2 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未デプロイ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "アンデプロイ中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "不明", + "studio.accessControl.entryLabel": "アクセス制御", + "studio.accessControl.paywallDescription": "信頼できる IP アドレスからのみ、このアプリにアクセスできるようにします。", + "studio.accessControl.paywallTitle": "アクセス制御", + "studio.accessControl.previewAppName": "コードコンパニオン", + "studio.accessControl.previewCaption": "このアプリは、組織のネットワーク上でのみ利用できます。", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "アクセス制御をオンにする", "studio.accessPoint.apiDescription": "HTTP 経由でアプリをサービスに統合", "studio.accessPoint.description": "このアプリにアクセスするすべての方法。アクセスポイントは実行中のバージョンのみに対応します。", "studio.accessPoint.embedIntoSite": "サイトに埋め込む", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index e7235629114d8e..8ed58ff22204e5 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "배포되지 않음", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "배포 취소 중", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "알 수 없음", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "서비스에서 HTTP로 앱 호출", "studio.accessPoint.description": "기본 제공 환경과 모든 배포 환경에서 이 앱에 접근하는 모든 방법입니다.", "studio.accessPoint.embedIntoSite": "사이트에 임베드", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index e6fca869da320d..64f89ac803bf92 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ຍັງບໍ່ໄດ້ຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "ກຳລັງຖອນການຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ບໍ່ຮູ້ຈັກ", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "ເອີ້ນໃຊ້ແອັບຈາກບໍລິການຂອງທ່ານຜ່ານ HTTP", "studio.accessPoint.description": "ທຸກວິທີທີ່ສາມາດເຂົ້າເຖິງແອັບນີ້ໄດ້ໃນສະພາບແວດລ້ອມ Built-in ແລະທຸກສະພາບແວດລ້ອມທີ່ deploy ແລ້ວ.", "studio.accessPoint.embedIntoSite": "ຝັງໄວ້ໃນເວັບໄຊ", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index f41bf709f365ae..a7a6ebb1376b91 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Niet geïmplementeerd", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Implementatie ongedaan maken", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Onbekend", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Roep de app via HTTP aan vanuit je services", "studio.accessPoint.description": "Alle manieren waarop deze app bereikbaar is in de ingebouwde omgeving en elke geïmplementeerde omgeving.", "studio.accessPoint.embedIntoSite": "In site insluiten", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index 0ba09b657c3448..058c2e3f2a6a26 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nie wdrożone", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Wycofywanie wdrożenia", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Nieznane", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Wywołuj aplikację z usług przez HTTP", "studio.accessPoint.description": "Wszystkie sposoby dostępu do tej aplikacji w środowisku wbudowanym i każdym wdrożonym środowisku.", "studio.accessPoint.embedIntoSite": "Osadź w witrynie", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index 8818f88bb72ed7..6c5c72f6cc908b 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Não implantado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Desimplantando", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconhecido", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chame o aplicativo a partir dos seus serviços via HTTP", "studio.accessPoint.description": "Todas as formas de acessar este aplicativo no ambiente integrado e em cada ambiente implantado.", "studio.accessPoint.embedIntoSite": "Incorporar ao site", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index 22efe318f8fbe8..882dd859431ec2 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Neimplementat", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Se anulează implementarea", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Necunoscut", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Apelează aplicația din serviciile tale prin HTTP", "studio.accessPoint.description": "Toate modalitățile prin care această aplicație poate fi accesată în mediul integrat și în fiecare mediu implementat.", "studio.accessPoint.embedIntoSite": "Încorporează în site", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index a15eff066d2f02..f27ef5a7ab1ddb 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не развернуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Свертывание развертывания", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Неизвестно", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Вызывайте приложение из своих сервисов по HTTP", "studio.accessPoint.description": "Все способы доступа к приложению во встроенной среде и каждой развернутой среде.", "studio.accessPoint.embedIntoSite": "Встроить на сайт", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 1a53ea271d3e59..7fc0994303dcce 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Ni uvedeno", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Razveljavljanje uvedbe", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Neznano", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Pokličite aplikacijo iz svojih storitev prek HTTP", "studio.accessPoint.description": "Vsi načini dostopa do te aplikacije v vgrajenem in vsakem uvedenem okolju.", "studio.accessPoint.embedIntoSite": "Vdelaj v spletno mesto", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index d323d910baecad..859fd6dcffbbca 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ยังไม่ได้ปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "กำลังยกเลิกการปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ไม่ทราบ", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "เรียกใช้แอปจากบริการของคุณผ่าน HTTP", "studio.accessPoint.description": "ทุกช่องทางที่เข้าถึงแอปนี้ได้ในสภาพแวดล้อม Built-in และทุกสภาพแวดล้อมที่ปรับใช้", "studio.accessPoint.embedIntoSite": "ฝังในเว็บไซต์", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index 091192ed63a816..3ae695d3abb284 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Dağıtılmadı", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Dağıtım geri alınıyor", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Bilinmiyor", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Uygulamayı hizmetlerinizden HTTP üzerinden çağırın", "studio.accessPoint.description": "Bu uygulamaya Yerleşik ortamda ve dağıtılan her ortamda erişmenin tüm yolları.", "studio.accessPoint.embedIntoSite": "Siteye göm", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index e26ac378db5fb1..0c7fd802f464e1 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не розгорнуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Скасування розгортання", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Невідомо", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Викликайте застосунок зі своїх сервісів через HTTP", "studio.accessPoint.description": "Усі способи доступу до застосунку у вбудованому та кожному розгорнутому середовищі.", "studio.accessPoint.embedIntoSite": "Вбудувати на сайт", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index d870942fdef540..ff8218441333f5 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Chưa triển khai", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Đang hủy triển khai", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Không xác định", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "Code Companion", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Gọi ứng dụng từ các dịch vụ của bạn qua HTTP", "studio.accessPoint.description": "Mọi cách có thể truy cập ứng dụng này trong môi trường tích hợp sẵn và mọi môi trường đã triển khai.", "studio.accessPoint.embedIntoSite": "Nhúng vào trang web", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index fe2ef3ad1ff0e9..d9f9d4bb0572dd 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下线中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessControl.entryLabel": "访问控制", + "studio.accessControl.paywallDescription": "将此应用限制为仅允许你信任的 IP 地址访问。", + "studio.accessControl.paywallTitle": "访问控制", + "studio.accessControl.previewAppName": "代码助手", + "studio.accessControl.previewCaption": "此应用仅允许在组织网络中访问。", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "开启访问控制", "studio.accessPoint.apiDescription": "通过 HTTP 将应用集成到你的服务中", "studio.accessPoint.description": "此应用的所有访问方式。访问点仅对应当前运行版本。", "studio.accessPoint.embedIntoSite": "嵌入网站", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index 32d9da399bec16..97bd64423154ea 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -33,6 +33,13 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下線中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", + "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.previewAppName": "程式助手", + "studio.accessControl.previewCaption": "This app is only available on your organization's network.", + "studio.accessControl.proBadge": "PRO", + "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "透過 HTTP 從你的服務呼叫應用程式", "studio.accessPoint.description": "應用程式在內建環境和每個已部署環境中的所有存取方式。", "studio.accessPoint.embedIntoSite": "嵌入網站", From 3b9b9d4d4f3b4bffd5cd1d741a6dc18a006206a9 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 8 Sep 2026 15:44:56 +0800 Subject: [PATCH 009/108] feat(web): add Access Control L2 config panel on Access Point Paid Cloud workspaces open a config popover to pick an IP policy and apply it to access points. Sandbox still uses the paywall. Settings gains an IP Policies destination for the empty-state exit. --- .../__tests__/config-panel.spec.tsx | 195 ++++++++++++++++ .../access-control/__tests__/draft.spec.ts | 185 +++++++++++++++ .../access-control/__tests__/index.spec.tsx | 91 +++++++- .../access-control/config-panel.tsx | 131 +++++++++++ .../app/access-point/access-control/draft.ts | 123 ++++++++++ .../app/access-point/access-control/index.tsx | 109 ++++++++- .../access-control/policy-field.tsx | 217 ++++++++++++++++++ .../access-control/scope-list.tsx | 104 +++++++++ .../access-control/status-panel.tsx | 164 +++++++++++++ .../__tests__/constants.spec.ts | 1 + .../account-setting/__tests__/index.spec.tsx | 9 + .../header/account-setting/constants.ts | 2 + .../header/account-setting/index.tsx | 15 ++ .../ip-policies-page/index.tsx | 18 ++ web/i18n/ar-TN/common.json | 1 + web/i18n/ar-TN/deployments.json | 21 ++ web/i18n/de-DE/common.json | 1 + web/i18n/de-DE/deployments.json | 21 ++ web/i18n/en-US/common.json | 1 + web/i18n/en-US/deployments.json | 21 ++ web/i18n/es-ES/common.json | 1 + web/i18n/es-ES/deployments.json | 21 ++ web/i18n/fa-IR/common.json | 1 + web/i18n/fa-IR/deployments.json | 21 ++ web/i18n/fr-FR/common.json | 1 + web/i18n/fr-FR/deployments.json | 21 ++ web/i18n/hi-IN/common.json | 1 + web/i18n/hi-IN/deployments.json | 21 ++ web/i18n/id-ID/common.json | 1 + web/i18n/id-ID/deployments.json | 21 ++ web/i18n/it-IT/common.json | 1 + web/i18n/it-IT/deployments.json | 21 ++ web/i18n/ja-JP/common.json | 1 + web/i18n/ja-JP/deployments.json | 21 ++ web/i18n/ko-KR/common.json | 1 + web/i18n/ko-KR/deployments.json | 21 ++ web/i18n/lo-LA/common.json | 1 + web/i18n/lo-LA/deployments.json | 21 ++ web/i18n/nl-NL/common.json | 1 + web/i18n/nl-NL/deployments.json | 21 ++ web/i18n/pl-PL/common.json | 1 + web/i18n/pl-PL/deployments.json | 21 ++ web/i18n/pt-BR/common.json | 1 + web/i18n/pt-BR/deployments.json | 21 ++ web/i18n/ro-RO/common.json | 1 + web/i18n/ro-RO/deployments.json | 21 ++ web/i18n/ru-RU/common.json | 1 + web/i18n/ru-RU/deployments.json | 21 ++ web/i18n/sl-SI/common.json | 1 + web/i18n/sl-SI/deployments.json | 21 ++ web/i18n/th-TH/common.json | 1 + web/i18n/th-TH/deployments.json | 21 ++ web/i18n/tr-TR/common.json | 1 + web/i18n/tr-TR/deployments.json | 21 ++ web/i18n/uk-UA/common.json | 1 + web/i18n/uk-UA/deployments.json | 21 ++ web/i18n/vi-VN/common.json | 1 + web/i18n/vi-VN/deployments.json | 21 ++ web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hans/deployments.json | 21 ++ web/i18n/zh-Hant/common.json | 1 + web/i18n/zh-Hant/deployments.json | 21 ++ 62 files changed, 1875 insertions(+), 17 deletions(-) create mode 100644 web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx create mode 100644 web/app/components/app/access-point/access-control/__tests__/draft.spec.ts create mode 100644 web/app/components/app/access-point/access-control/config-panel.tsx create mode 100644 web/app/components/app/access-point/access-control/draft.ts create mode 100644 web/app/components/app/access-point/access-control/policy-field.tsx create mode 100644 web/app/components/app/access-point/access-control/scope-list.tsx create mode 100644 web/app/components/app/access-point/access-control/status-panel.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/index.tsx diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx new file mode 100644 index 00000000000000..80807b3982645b --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -0,0 +1,195 @@ +import type { + AccessControlDraft, + AccessControlPolicy, + AccessControlScopeAvailability, +} from '../draft' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { render } from '@/test/console/render' +import { AccessControlConfigPanel } from '../config-panel' +import { createDefaultAccessControlDraft } from '../draft' + +const allScopesAvailable: AccessControlScopeAvailability = { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, +} + +const policies: AccessControlPolicy[] = [ + { + id: 'internal-network', + name: 'Internal Network', + addresses: ['203.0.113.42', '198.51.100.0/24', '192.0.2.1', '192.0.2.2'], + }, + { + id: 'office-vpn', + name: 'Office VPN', + addresses: ['198.51.100.0/24'], + }, +] + +const translations = vi.hoisted(() => ({ + 'operation.cancel': 'Cancel', + 'operation.save': 'Save', + 'overview.apiInfo.title': 'Backend Service API', + 'overview.appInfo.title': 'Web App', + 'mcp.server.title': 'MCP Server', + 'settings.ipPolicies': 'IP Policies', + 'settings.trigger': 'Trigger', + 'studio.accessControl.addressCount': '{{count}} addresses', + 'studio.accessControl.applyTo': 'Apply to', + 'studio.accessControl.applyToHelp': 'Choose which access points to protect.', + 'studio.accessControl.applyToHelpSelected': 'Choose which access points this policy protects.', + 'studio.accessControl.entryLabel': 'Access Control', + 'studio.accessControl.ipPolicy': 'IP Policy', + 'studio.accessControl.lockoutWarning': + "Your IP ({{ip}}) isn't in this policy. You may lose access.", + 'studio.accessControl.manageIpPolicies': 'Manage IP policies', + 'studio.accessControl.notEnabled': 'Not enabled', + 'studio.accessControl.policySummaryMany': 'Allows {{listed}} and {{count}} more addresses', + 'studio.accessControl.policySummaryOne': 'Allows {{address}}', + 'studio.accessControl.selectAccessPoint': 'Select at least one access point to protect.', + 'studio.accessControl.selectPolicy': 'Select a policy', +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock(translations) +}) + +function PanelHarness({ + availability = allScopesAvailable, + currentIp, + initialDraft = createDefaultAccessControlDraft(), + onSave = vi.fn(), +}: { + availability?: AccessControlScopeAvailability + currentIp?: string + initialDraft?: AccessControlDraft + onSave?: () => void +}) { + const [draft, setDraft] = useState(initialDraft) + const onCancel = vi.fn() + const onCreatePolicy = vi.fn() + const onManagePolicies = vi.fn() + + return ( + + Access Control + + + + + ) +} + +describe('AccessControlConfigPanel', () => { + it('opens the policy select when policies exist and none is selected', async () => { + render() + + expect(await screen.findByRole('option', { name: /Internal Network/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + }) + + it('enables save after a policy is selected', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + render() + + await user.click(await screen.findByRole('option', { name: /Internal Network/ })) + + expect( + screen.getByText('Allows 203.0.113.42, 198.51.100.0/24 and 2 more addresses'), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + + await user.click(screen.getByRole('button', { name: 'Save' })) + expect(onSave).toHaveBeenCalledTimes(1) + }) + + it('warns and disables save when every protectable access point is off', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('option', { name: /Internal Network/ })) + await user.click(screen.getByRole('switch', { name: 'Web App' })) + await user.click(screen.getByRole('switch', { name: 'Backend Service API' })) + + expect(screen.getByText('Select at least one access point to protect.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('shows a lockout warning when the current IP is outside the selected policy', async () => { + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('option', { name: /Office VPN/ })) + + expect( + screen.getByText("Your IP (203.0.113.42) isn't in this policy. You may lose access."), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + }) + + it('marks an unavailable MCP server as not enabled', () => { + render( + , + ) + + expect(screen.getByText('Not enabled')).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) + + it('disables MCP and Trigger for agent apps', () => { + render( + , + ) + + expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Trigger' })).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts new file mode 100644 index 00000000000000..f4d202d5244d13 --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts @@ -0,0 +1,185 @@ +import { AppModeEnum } from '@/types/app' +import { + canSaveAccessControl, + createDefaultAccessControlDraft, + getAccessControlScopeAvailability, + getAccessControlScopeSupport, + policyIncludesIp, + splitPolicySummary, +} from '../draft' + +const allScopesAvailable = { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, +} + +const selectedDraft = { + ...createDefaultAccessControlDraft(), + selectedPolicyId: 'policy-1', +} + +describe('canSaveAccessControl', () => { + it('disables save when no policy is selected', () => { + expect( + canSaveAccessControl({ + draft: createDefaultAccessControlDraft(), + availability: { ...allScopesAvailable, trigger: false }, + }), + ).toBe(false) + }) + + it('disables save when every protectable access point is off', () => { + expect( + canSaveAccessControl({ + draft: { + selectedPolicyId: 'policy-1', + scopes: { + webApp: false, + serviceApi: false, + mcp: false, + trigger: true, + }, + }, + availability: { ...allScopesAvailable, trigger: false }, + }), + ).toBe(false) + }) + + it('enables save when a policy is selected and at least one access point is on', () => { + expect( + canSaveAccessControl({ + draft: selectedDraft, + availability: { ...allScopesAvailable, trigger: false }, + }), + ).toBe(true) + }) + + it('counts an enabled trigger only when that access point is available', () => { + expect( + canSaveAccessControl({ + draft: { + selectedPolicyId: 'policy-1', + scopes: { + webApp: false, + serviceApi: false, + mcp: false, + trigger: true, + }, + }, + availability: allScopesAvailable, + }), + ).toBe(true) + }) + + it('does not count an unavailable MCP server toward save', () => { + expect( + canSaveAccessControl({ + draft: { + selectedPolicyId: 'policy-1', + scopes: { + webApp: false, + serviceApi: false, + mcp: true, + trigger: false, + }, + }, + availability: { ...allScopesAvailable, mcp: false, trigger: false }, + }), + ).toBe(false) + }) +}) + +describe('getAccessControlScopeSupport', () => { + it('lets workflow apps apply to every access point', () => { + expect(getAccessControlScopeSupport(AppModeEnum.WORKFLOW)).toEqual({ + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, + }) + }) + + it('lets agent apps apply only to Web App and Backend Service API', () => { + expect(getAccessControlScopeSupport(AppModeEnum.AGENT)).toEqual({ + webApp: true, + serviceApi: true, + mcp: false, + trigger: false, + }) + }) + + it.each([ + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.CHAT, + AppModeEnum.COMPLETION, + AppModeEnum.AGENT_CHAT, + ])('does not let %s apps apply to Trigger', (mode) => { + expect(getAccessControlScopeSupport(mode)).toEqual({ + webApp: true, + serviceApi: true, + mcp: true, + trigger: false, + }) + }) +}) + +describe('getAccessControlScopeAvailability', () => { + it('keeps workflow Trigger off until a published trigger node exists', () => { + expect( + getAccessControlScopeAvailability({ + mode: AppModeEnum.WORKFLOW, + hasTriggerNode: false, + isUnpublished: false, + }).trigger, + ).toBe(false) + expect( + getAccessControlScopeAvailability({ + mode: AppModeEnum.WORKFLOW, + hasTriggerNode: true, + isUnpublished: true, + }).trigger, + ).toBe(false) + expect( + getAccessControlScopeAvailability({ + mode: AppModeEnum.WORKFLOW, + hasTriggerNode: true, + isUnpublished: false, + }).trigger, + ).toBe(true) + }) +}) + +describe('splitPolicySummary', () => { + it('keeps one or two addresses listed', () => { + expect(splitPolicySummary(['203.0.113.42'])).toEqual({ + listed: ['203.0.113.42'], + moreCount: 0, + }) + expect(splitPolicySummary(['203.0.113.42', '198.51.100.0/24'])).toEqual({ + listed: ['203.0.113.42', '198.51.100.0/24'], + moreCount: 0, + }) + }) + + it('summarizes additional addresses after the first two', () => { + expect( + splitPolicySummary(['203.0.113.42', '198.51.100.0/24', '192.0.2.1', '192.0.2.2']), + ).toEqual({ + listed: ['203.0.113.42', '198.51.100.0/24'], + moreCount: 2, + }) + }) +}) + +describe('policyIncludesIp', () => { + it('matches an exact address and a covering CIDR', () => { + expect(policyIncludesIp(['203.0.113.42', '198.51.100.0/24'], '203.0.113.42')).toBe(true) + expect(policyIncludesIp(['198.51.100.0/24'], '198.51.100.20')).toBe(true) + }) + + it('does not match an address outside the policy', () => { + expect(policyIncludesIp(['198.51.100.0/24'], '203.0.113.42')).toBe(false) + }) +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 197c6ebf07bc42..5c6ab09626aa6b 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -6,8 +6,23 @@ import { renderWithConsoleQuery } from '@/test/console/query-data' import { AccessControlEntry } from '..' const mockSetShowPricingModal = vi.fn() +const mockSetSettingsDestination = vi.fn() const accessControlTranslations = vi.hoisted(() => ({ + 'operation.cancel': 'Cancel', + 'operation.save': 'Save', + 'overview.apiInfo.title': 'Backend Service API', + 'overview.appInfo.title': 'Web App', + 'mcp.server.title': 'MCP Server', + 'settings.trigger': 'Trigger', + 'studio.accessControl.applyTo': 'Apply to', + 'studio.accessControl.applyToHelp': 'Choose which access points to protect.', + 'studio.accessControl.createIpPolicy': 'Create an IP policy', + 'studio.accessControl.emptyPoliciesDescription': + 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', + 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', 'studio.accessControl.entryLabel': 'Access Control', + 'studio.accessControl.ipPolicy': 'IP Policy', + 'studio.accessControl.notEnabled': 'Not enabled', 'studio.accessControl.paywallDescription': 'Restrict this app to IP addresses you trust.', 'studio.accessControl.paywallTitle': 'Access Control', 'studio.accessControl.previewAppName': 'Code Companion', @@ -22,6 +37,11 @@ vi.mock('@/context/modal-context', () => ({ useModalContextSelector: vi.fn(), })) +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } +}) + vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') return createReactI18nextMock(accessControlTranslations) @@ -76,12 +96,6 @@ describe('AccessControlEntry', () => { expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() }) - it.each(['professional', 'team'] as const)('does not render on Cloud %s', (plan) => { - renderEntry({ plan }) - - expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() - }) - it('renders a single chip with a non-interactive PRO badge for Cloud sandbox', () => { renderEntry({ plan: 'sandbox' }) @@ -91,6 +105,17 @@ describe('AccessControlEntry', () => { expect(within(chip).queryByRole('button')).not.toBeInTheDocument() }) + it.each(['professional', 'team'] as const)( + 'renders the Access Control chip without a PRO badge on Cloud %s', + (plan) => { + renderEntry({ plan }) + + const chip = getChip() + expect(chip).toBeInTheDocument() + expect(within(chip).queryByText('PRO')).not.toBeInTheDocument() + }, + ) + it('opens the paywall popover from the chip and sends the user to pricing', async () => { const user = userEvent.setup() renderEntry({ plan: 'sandbox' }) @@ -126,4 +151,58 @@ describe('AccessControlEntry', () => { }) expect(mockSetShowPricingModal).not.toHaveBeenCalled() }) + + it('opens the first-time config popover for paid workspaces without a back control', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + + expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() + expect( + screen.getByText( + 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', + ), + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('switch', { name: 'Backend Service API' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Trigger' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByText('Not enabled')).toBeInTheDocument() + }) + + it('sends paid users to Settings IP Policies from the empty-state exit', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + + expect(mockSetSettingsDestination).toHaveBeenCalledWith('ip-policies') + await waitFor(() => { + expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + }) + }) + + it('closes the first-time config on Cancel without opening settings', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + }) + expect(mockSetSettingsDestination).not.toHaveBeenCalled() + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx new file mode 100644 index 00000000000000..27f3b0d797c5cf --- /dev/null +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -0,0 +1,131 @@ +'use client' + +import type { FormEvent } from 'react' +import type { + AccessControlDraft, + AccessControlPolicy, + AccessControlScopeAvailability, +} from './draft' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { PopoverTitle } from '@langgenius/dify-ui/popover' +import { useTranslation } from 'react-i18next' +import { canSaveAccessControl } from './draft' +import { AccessControlPolicyField } from './policy-field' +import { AccessControlScopeList } from './scope-list' + +type AccessControlConfigPanelProps = { + draft: AccessControlDraft + policies: readonly AccessControlPolicy[] + currentIp?: string + availability: AccessControlScopeAvailability + showBack?: boolean + onBack?: () => void + onCancel: () => void + onCreatePolicy: () => void + onManagePolicies: () => void + onDraftChange: (draft: AccessControlDraft) => void + onSave: () => void +} + +export function AccessControlConfigPanel({ + draft, + policies, + currentIp, + availability, + showBack = false, + onBack, + onCancel, + onCreatePolicy, + onManagePolicies, + onDraftChange, + onSave, +}: AccessControlConfigPanelProps) { + const { t } = useTranslation() + const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) + const hasSelectedPolicy = Boolean(draft.selectedPolicyId) + const canSave = canSaveAccessControl({ draft, availability }) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + if (!canSave) return + onSave() + } + + return ( +

+
+
+ {showBack && ( + $['operation.back'], { ns: 'common' })} + onClick={onBack} + > + + + )} + + {title} + +
+
+ +
+
+ + {t(($) => $['studio.accessControl.ipPolicy'], { ns: 'deployments' })} + + { + onDraftChange({ ...draft, selectedPolicyId: policyId }) + }} + /> +
+ +
+ + {t(($) => $['studio.accessControl.applyTo'], { ns: 'deployments' })} + +

+ {hasSelectedPolicy + ? t(($) => $['studio.accessControl.applyToHelpSelected'], { ns: 'deployments' }) + : t(($) => $['studio.accessControl.applyToHelp'], { ns: 'deployments' })} +

+ + {hasSelectedPolicy && !canSave && ( +

+ {t(($) => $['studio.accessControl.selectAccessPoint'], { ns: 'deployments' })} +

+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/web/app/components/app/access-point/access-control/draft.ts b/web/app/components/app/access-point/access-control/draft.ts new file mode 100644 index 00000000000000..c9eb0aac937e31 --- /dev/null +++ b/web/app/components/app/access-point/access-control/draft.ts @@ -0,0 +1,123 @@ +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' +import { AppModeEnum } from '@/types/app' + +export type AccessControlScopeAvailability = Record + +export type AccessControlPolicy = { + id: string + name: string + addresses: readonly string[] +} + +export type AccessControlDraft = { + selectedPolicyId: string | null + scopes: Record +} + +export const createDefaultAccessControlDraft = (): AccessControlDraft => ({ + selectedPolicyId: null, + scopes: { + webApp: true, + serviceApi: true, + mcp: false, + trigger: false, + }, +}) + +export function getAccessControlScopeSupport( + mode: AppModeEnum | undefined, +): AccessControlScopeAvailability { + if (mode === AppModeEnum.AGENT) { + return { webApp: true, serviceApi: true, mcp: false, trigger: false } + } + + if (mode === AppModeEnum.WORKFLOW) { + return { webApp: true, serviceApi: true, mcp: true, trigger: true } + } + + return { webApp: true, serviceApi: true, mcp: true, trigger: false } +} + +export function getAccessControlScopeAvailability({ + mode, + hasTriggerNode, + isUnpublished, +}: { + mode: AppModeEnum | undefined + hasTriggerNode: boolean + isUnpublished: boolean +}): AccessControlScopeAvailability { + const support = getAccessControlScopeSupport(mode) + + return { + ...support, + trigger: support.trigger && !isUnpublished && hasTriggerNode, + } +} + +export function isProtectableAccessPoint( + scope: AccessPoint, + availability: AccessControlScopeAvailability, +) { + return availability[scope] +} + +export function canSaveAccessControl({ + draft, + availability, +}: { + draft: AccessControlDraft + availability: AccessControlScopeAvailability +}) { + if (!draft.selectedPolicyId) return false + + return ACCESS_POINT_ORDER.some((scope) => { + if (!isProtectableAccessPoint(scope, availability)) return false + return draft.scopes[scope] + }) +} + +export function splitPolicySummary(addresses: readonly string[]): { + listed: readonly string[] + moreCount: number +} { + if (addresses.length <= 2) return { listed: addresses, moreCount: 0 } + return { listed: addresses.slice(0, 2), moreCount: addresses.length - 2 } +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.') + if (parts.length !== 4) return null + + let value = 0 + for (const part of parts) { + if (!/^\d+$/.test(part)) return null + const octet = Number(part) + if (octet > 255) return null + value = (value << 8) + octet + } + + return value >>> 0 +} + +function ipv4InCidr(ip: string, network: string, prefix: number): boolean { + const ipInt = ipv4ToInt(ip) + const networkInt = ipv4ToInt(network) + if (ipInt === null || networkInt === null) return false + if (prefix === 0) return true + const mask = prefix === 32 ? 0xffffffff : ~((1 << (32 - prefix)) - 1) >>> 0 + return (ipInt & mask) === (networkInt & mask) +} + +export function policyIncludesIp(addresses: readonly string[], ip: string): boolean { + return addresses.some((entry) => { + if (entry === ip) return true + const slash = entry.indexOf('/') + if (slash === -1) return false + const network = entry.slice(0, slash) + const prefix = Number(entry.slice(slash + 1)) + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false + return ipv4InCidr(ip, network, prefix) + }) +} diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index e7904bb096214b..8d67ea4e28ad78 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -1,17 +1,32 @@ 'use client' +import type { AccessControlDraft } from './draft' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' +import { useQueryState } from 'nuqs' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import PremiumBadge from '@/app/components/base/premium-badge' +import { + settingsQueryParamName, + settingsQueryParser, +} from '@/app/components/header/account-setting/query-params' import { useModalContext } from '@/context/modal-context' import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' +import { useAppWorkflow } from '@/service/use-workflow' +import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' +import { AccessControlConfigPanel } from './config-panel' +import { createDefaultAccessControlDraft, getAccessControlScopeAvailability } from './draft' import { AccessControlFreePaywall } from './free-paywall' +import { AccessControlStatusPanel } from './status-panel' type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void +const isPaidCloudPlan = (plan: string | undefined) => plan === 'professional' || plan === 'team' + export function AccessControlEntry() { const { t } = useTranslation() const deploymentEdition = useAtomValue(deploymentEditionAtom) @@ -22,11 +37,29 @@ export function AccessControlEntry() { }), ) const { setShowPricingModal } = useModalContext() + const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) + const appInfo = useAppStore((state) => state.appDetail) + const isSandbox = plan === 'sandbox' + const isPaid = isPaidCloudPlan(plan) + const shouldFetchWorkflow = isPaid && Boolean(appInfo && isAdvancedApp(appInfo)) + const { data: workflow } = useAppWorkflow(shouldFetchWorkflow && appInfo ? appInfo.id : '') + const [open, setOpen] = useState(false) + const [view, setView] = useState<'config' | 'status'>('config') + const [showBack, setShowBack] = useState(false) + const [draft, setDraft] = useState(createDefaultAccessControlDraft) + const [savedDraft, setSavedDraft] = useState(null) - if (deploymentEdition !== 'CLOUD' || plan !== 'sandbox') return null + if (deploymentEdition !== 'CLOUD' || (!isSandbox && !isPaid)) return null const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) + const workflowState = appInfo ? getPublishedWorkflowState(appInfo, workflow) : undefined + const availability = getAccessControlScopeAvailability({ + mode: appInfo?.mode, + hasTriggerNode: Boolean(workflowState?.hasTriggerNode), + isUnpublished: Boolean(workflowState?.isUnpublished), + }) + const policies = [] as const const handleTurnOn = () => { setShowPricingModal() @@ -34,24 +67,78 @@ export function AccessControlEntry() { if (gtag) gtag('event', 'click_upgrade_btn', { loc: 'access-control-paywall' }) } + const resetDraft = () => { + setDraft(savedDraft ?? createDefaultAccessControlDraft()) + } + + const handleCancel = () => { + resetDraft() + if (savedDraft) { + setView('status') + setShowBack(false) + return + } + setOpen(false) + } + + const handleCreatePolicy = () => { + setOpen(false) + void setSettingsDestination('ip-policies') + } + + const handleSave = () => { + setSavedDraft(draft) + setView('status') + setShowBack(false) + } + return ( - + {label} - - - {pro} - + {isSandbox && ( + + + {pro} + + )} - + {isSandbox ? ( + + ) : view === 'status' && savedDraft ? ( + { + setDraft(savedDraft) + setShowBack(true) + setView('config') + }} + /> + ) : ( + + )} ) diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx new file mode 100644 index 00000000000000..fd07e370cbbeec --- /dev/null +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -0,0 +1,217 @@ +'use client' + +import type { AccessControlPolicy } from './draft' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' +import { useTranslation } from 'react-i18next' +import { policyIncludesIp, splitPolicySummary } from './draft' + +const MANAGE_POLICIES_VALUE = '__manage-ip-policies__' + +type AccessControlPolicyFieldProps = { + policies: readonly AccessControlPolicy[] + selectedPolicyId: string | null + currentIp?: string + onCreatePolicy: () => void + onManagePolicies: () => void + onSelectPolicy: (policyId: string) => void +} + +export function AccessControlPolicyField({ + policies, + selectedPolicyId, + currentIp, + onCreatePolicy, + onManagePolicies, + onSelectPolicy, +}: AccessControlPolicyFieldProps) { + const { t } = useTranslation() + const selectedPolicy = policies.find((policy) => policy.id === selectedPolicyId) + const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.addresses) : undefined + const showLockout = + Boolean(selectedPolicy && currentIp) && + selectedPolicy !== undefined && + currentIp !== undefined && + !policyIncludesIp(selectedPolicy.addresses, currentIp) + const selectLabel = t(($) => $['studio.accessControl.ipPolicy'], { ns: 'deployments' }) + const placeholder = t(($) => $['studio.accessControl.selectPolicy'], { ns: 'deployments' }) + + if (policies.length === 0) { + return ( +
+
+

+ {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} +

+

+ {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} +

+
+ +
+ ) + } + + const policyOptions = ( + <> + {policies.map((policy) => ( + + {policy.name} + + {t(($) => $['studio.accessControl.addressCount'], { + ns: 'deployments', + count: policy.addresses.length, + })} + + + + ))} + + + + {t(($) => $['studio.accessControl.manageIpPolicies'], { ns: 'deployments' })} + + + + ) + + if (!selectedPolicy) { + return ( +
+
+
+ + {placeholder} + + +
+ $['settings.ipPolicies'], { ns: 'common' })} + onClick={onManagePolicies} + > + + +
+
+ {policies.map((policy) => ( + + ))} +
+ +
+
+ ) + } + + return ( + <> +
+ + $['settings.ipPolicies'], { ns: 'common' })} + onClick={onManagePolicies} + > + + +
+ {summary && ( +

+ {summary.listed.length === 0 + ? t(($) => $['studio.accessControl.policySummaryNone'], { ns: 'deployments' }) + : summary.listed.length === 1 + ? t(($) => $['studio.accessControl.policySummaryOne'], { + ns: 'deployments', + address: summary.listed[0], + }) + : summary.moreCount > 0 + ? t(($) => $['studio.accessControl.policySummaryMany'], { + ns: 'deployments', + listed: summary.listed.join(', '), + count: summary.moreCount, + }) + : t(($) => $['studio.accessControl.policySummaryTwo'], { + ns: 'deployments', + first: summary.listed[0], + second: summary.listed[1], + })} +

+ )} + {showLockout && currentIp && ( +

+ {t(($) => $['studio.accessControl.lockoutWarning'], { + ns: 'deployments', + ip: currentIp, + })} +

+ )} + + ) +} diff --git a/web/app/components/app/access-point/access-control/scope-list.tsx b/web/app/components/app/access-point/access-control/scope-list.tsx new file mode 100644 index 00000000000000..d3039743aeed96 --- /dev/null +++ b/web/app/components/app/access-point/access-control/scope-list.tsx @@ -0,0 +1,104 @@ +'use client' + +import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { cn } from '@langgenius/dify-ui/cn' +import { Switch } from '@langgenius/dify-ui/switch' +import { useTranslation } from 'react-i18next' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' +import { useStore as useAppStore } from '@/app/components/app/store' +import AppIcon from '@/app/components/base/app-icon' + +const SCOPE_ICONS: Record, string> = { + serviceApi: 'i-custom-vender-knowledge-api-aggregate', + mcp: 'i-custom-vender-integrations-mcp', + trigger: 'i-custom-vender-integrations-trigger', +} + +type AccessControlScopeListProps = { + draft: AccessControlDraft + disabled: boolean + availability: AccessControlScopeAvailability + onDraftChange: (draft: AccessControlDraft) => void +} + +export function AccessControlScopeList({ + draft, + disabled, + availability, + onDraftChange, +}: AccessControlScopeListProps) { + const { t } = useTranslation() + const appInfo = useAppStore((state) => state.appDetail) + + const labels: Record = { + webApp: t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }), + serviceApi: t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }), + mcp: t(($) => $['mcp.server.title'], { ns: 'tools' }), + trigger: t(($) => $['settings.trigger'], { ns: 'common' }), + } + + return ( +
+ {ACCESS_POINT_ORDER.map((scope) => { + const unavailable = !availability[scope] + const label = labels[scope] + + return ( +
+ {scope === 'webApp' ? ( + + ) : ( + + + + )} + + {label} + + {unavailable && ( + + {t(($) => $['studio.accessControl.notEnabled'], { ns: 'deployments' })} + + )} + { + onDraftChange({ + ...draft, + scopes: { + ...draft.scopes, + [scope]: checked, + }, + }) + }} + /> +
+ ) + })} +
+ ) +} diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx new file mode 100644 index 00000000000000..df183b9d749b9e --- /dev/null +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -0,0 +1,164 @@ +'use client' + +import type { + AccessControlDraft, + AccessControlPolicy, + AccessControlScopeAvailability, +} from './draft' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { PopoverTitle } from '@langgenius/dify-ui/popover' +import { Switch } from '@langgenius/dify-ui/switch' +import { useTranslation } from 'react-i18next' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' +import { useStore as useAppStore } from '@/app/components/app/store' +import AppIcon from '@/app/components/base/app-icon' +import { isProtectableAccessPoint, splitPolicySummary } from './draft' + +const SCOPE_ICONS: Record, string> = { + serviceApi: 'i-custom-vender-knowledge-api-aggregate', + mcp: 'i-custom-vender-integrations-mcp', + trigger: 'i-custom-vender-integrations-trigger', +} + +type AccessControlStatusPanelProps = { + draft: AccessControlDraft + policies: readonly AccessControlPolicy[] + enabled: boolean + availability: AccessControlScopeAvailability + onEdit: () => void +} + +export function AccessControlStatusPanel({ + draft, + policies, + enabled, + availability, + onEdit, +}: AccessControlStatusPanelProps) { + const { t } = useTranslation() + const appInfo = useAppStore((state) => state.appDetail) + const selectedPolicy = policies.find((policy) => policy.id === draft.selectedPolicyId) + const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.addresses) : undefined + const labels: Record = { + webApp: t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }), + serviceApi: t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }), + mcp: t(($) => $['mcp.server.title'], { ns: 'tools' }), + trigger: t(($) => $['settings.trigger'], { ns: 'common' }), + } + + return ( +
+
+ + {t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' })} + + +
+ +
+
+

+ {t(($) => $['studio.accessControl.restrictByIp'], { ns: 'deployments' })} +

+ $['studio.accessControl.restrictByIp'], { ns: 'deployments' })} + /> +
+ + {selectedPolicy && summary && ( +
+

+ {t(($) => $['studio.accessControl.ipPolicy'], { ns: 'deployments' })} +

+

{selectedPolicy.name}

+

+ {summary.listed.length === 0 + ? t(($) => $['studio.accessControl.policySummaryNone'], { ns: 'deployments' }) + : summary.listed.length === 1 + ? t(($) => $['studio.accessControl.policySummaryOne'], { + ns: 'deployments', + address: summary.listed[0], + }) + : summary.moreCount > 0 + ? t(($) => $['studio.accessControl.policySummaryMany'], { + ns: 'deployments', + listed: summary.listed.join(', '), + count: summary.moreCount, + }) + : t(($) => $['studio.accessControl.policySummaryTwo'], { + ns: 'deployments', + first: summary.listed[0], + second: summary.listed[1], + })} +

+
+ )} + +
+

+ {t(($) => $['studio.accessControl.applyTo'], { ns: 'deployments' })} +

+ {ACCESS_POINT_ORDER.map((scope) => { + const unavailable = !isProtectableAccessPoint(scope, availability) + const excluded = !unavailable && !draft.scopes[scope] + const label = labels[scope] + + return ( +
+ {scope === 'webApp' ? ( + + ) : ( + + + + )} + + {label} + + {excluded && ( + + {t(($) => $['studio.accessControl.excluded'], { ns: 'deployments' })} + + )} + {unavailable && ( + + {t(($) => $['studio.accessControl.notEnabled'], { ns: 'deployments' })} + + )} +
+ ) + })} +
+
+
+ ) +} diff --git a/web/app/components/header/account-setting/__tests__/constants.spec.ts b/web/app/components/header/account-setting/__tests__/constants.spec.ts index aa21ac9e18bf8e..015db9ec3aacd3 100644 --- a/web/app/components/header/account-setting/__tests__/constants.spec.ts +++ b/web/app/components/header/account-setting/__tests__/constants.spec.ts @@ -7,6 +7,7 @@ import { describe('settingsQueryParser', () => { it.each([ ['roles-and-permissions', 'roles-and-permissions'], + ['ip-policies', 'ip-policies'], ['preferences', 'preferences'], ['provider', 'provider'], ['mcp', 'mcp'], diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index bfdebe8331c4c4..b117b174a518f7 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -552,6 +552,15 @@ describe('AccountSetting', () => { }) describe('Tab Navigation', () => { + it('should open the IP Policies settings page', () => { + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IP_POLICIES }) + + expect(screen.getByRole('button', { name: 'common.settings.ipPolicies' })).toBeInTheDocument() + expect( + screen.getByText('deployments.studio.accessControl.emptyPoliciesTitle'), + ).toBeInTheDocument() + }) + it('should change active tab when clicking on menu item', () => { // Arrange renderAccountSetting({ onTabChange: mockOnTabChange }) diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 78e0ef3e95e9af..15a4821a680a33 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -1,5 +1,6 @@ export const ACCOUNT_SETTING_TAB = { MEMBERS: 'members', + IP_POLICIES: 'ip-policies', ROLES_AND_PERMISSIONS: 'roles-and-permissions', PERMISSION_SET: 'permission-set', BILLING: 'billing', @@ -12,6 +13,7 @@ export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUN export const ACCOUNT_SETTING_TAB_VALUES = [ ACCOUNT_SETTING_TAB.MEMBERS, + ACCOUNT_SETTING_TAB.IP_POLICIES, ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, ACCOUNT_SETTING_TAB.PERMISSION_SET, ACCOUNT_SETTING_TAB.BILLING, diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 68cbb9933c8745..33ab1f4cbf6aa5 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -27,6 +27,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' import AccessRulesPage from './access-rules-page' +import IpPoliciesPage from './ip-policies-page' import MembersPage from './members-page' import PermissionsPage from './permissions-page' import PreferencePage from './preference-page' @@ -74,6 +75,11 @@ export default function AccountSetting({ const canViewWorkflowLogArchives = systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager const activeMenu = (() => { + if ( + activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && + systemFeatures.deployment_edition !== 'CLOUD' + ) + return ACCOUNT_SETTING_TAB.MEMBERS if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling) return ACCOUNT_SETTING_TAB.PREFERENCES if (activeTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && !canViewWorkflowLogArchives) @@ -95,6 +101,12 @@ export default function AccountSetting({ icon: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.IP_POLICIES, + name: t(($) => $['settings.ipPolicies'], { ns: 'common' }), + icon: , + activeIcon: , + }, { key: ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, name: t(($) => $['settings.rolesAndPermissions'], { ns: 'common' }), @@ -142,6 +154,8 @@ export default function AccountSetting({ const visibleTabs: AccountSettingTab[] = [] visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS) + if (systemFeatures.deployment_edition === 'CLOUD') + visibleTabs.push(ACCOUNT_SETTING_TAB.IP_POLICIES) if (canManageWorkspaceRoles) { visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS) @@ -247,6 +261,7 @@ export default function AccountSetting({
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && } + {activeMenu === ACCOUNT_SETTING_TAB.IP_POLICIES && } {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( )} diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx new file mode 100644 index 00000000000000..4adafc1ca57171 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -0,0 +1,18 @@ +'use client' + +import { useTranslation } from 'react-i18next' + +export default function IpPoliciesPage() { + const { t } = useTranslation() + + return ( +
+

+ {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} +

+

+ {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} +

+
+ ) +} diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index cb58e11eacbb8c..974cd883965779 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "مصدر البيانات", "settings.extension": "Extension", "settings.integrations": "التكاملات", + "settings.ipPolicies": "IP Policies", "settings.members": "الأعضاء", "settings.permissionSet": "مجموعة الأذونات", "settings.permissionSetDescription": "قم بتكوين مجموعات الأذونات لاستخدامها مع التطبيقات وقواعد المعرفة. مجموعة الأذونات هي مجموعة قابلة لإعادة الاستخدام من أذونات عمليات الموارد يمكن تعيينها للأعضاء لموارد محددة.", diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index 36f7828971125c..6967b45226f9ed 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "غير منشور", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "جاري إلغاء النشر", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "غير معروف", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "استدعِ التطبيق من خدماتك عبر HTTP", "studio.accessPoint.description": "جميع طرق الوصول إلى هذا التطبيق في البيئة المدمجة وكل بيئة منشورة.", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 3eca0f3ce2fa75..8484f772532ef6 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Datenquelle", "settings.extension": "Extension", "settings.integrations": "Integrationen", + "settings.ipPolicies": "IP Policies", "settings.members": "Mitglieder", "settings.permissionSet": "Berechtigungssatz", "settings.permissionSetDescription": "Konfigurieren Sie Berechtigungssätze zur Verwendung mit Apps und Wissensdatenbanken. Ein Berechtigungssatz ist eine wiederverwendbare Sammlung von Berechtigungen für Ressourcenoperationen, die Mitgliedern für bestimmte Ressourcen zugewiesen werden kann.", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index 36cd854da8ca56..d8281bef9db2b2 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nicht bereitgestellt", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Bereitstellung wird aufgehoben", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unbekannt", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Rufen Sie die App über HTTP aus Ihren Diensten auf", "studio.accessPoint.description": "Alle Möglichkeiten, diese App in der integrierten Umgebung und jeder bereitgestellten Umgebung zu erreichen.", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index f7f58407bab21e..6065af40b840fa 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Data Source", "settings.extension": "Extension", "settings.integrations": "Integrations", + "settings.ipPolicies": "IP Policies", "settings.members": "Members", "settings.permissionSet": "Permission Set", "settings.permissionSetDescription": "Configure permission sets for use with applications and knowledge bases. A permission set is a reusable collection of resource operation permissions that can be assigned to members for specific resources.", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index add54fec723c72..ad64f218d884f1 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Not deployed", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Undeploying", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unknown", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Integrate the app into your services over HTTP", "studio.accessPoint.description": "All the ways this app can be reached. Access points match the running version only.", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 87bba9f08589ef..2404b986388404 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Fuente de Datos", "settings.extension": "Extension", "settings.integrations": "Integraciones", + "settings.ipPolicies": "IP Policies", "settings.members": "Miembros", "settings.permissionSet": "Conjunto de permisos", "settings.permissionSetDescription": "Configura conjuntos de permisos para usarlos con aplicaciones y bases de conocimiento. Un conjunto de permisos es una colección reutilizable de permisos de operaciones sobre recursos que se puede asignar a miembros para recursos específicos.", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index af4ee71d553435..d53482ed7d19cd 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "No desplegado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Retirando despliegue", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconocido", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Llama a la aplicación desde tus servicios a través de HTTP", "studio.accessPoint.description": "Todas las formas de acceder a esta aplicación en el entorno integrado y en cada entorno desplegado.", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 69c94939366a97..3ee10e59de2171 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "منبع داده", "settings.extension": "Extension", "settings.integrations": "ادغام‌ها", + "settings.ipPolicies": "IP Policies", "settings.members": "اعضا", "settings.permissionSet": "مجموعه مجوزها", "settings.permissionSetDescription": "مجموعه‌های مجوز را برای استفاده با برنامه‌ها و پایگاه‌های دانش پیکربندی کنید. مجموعه مجوز، مجموعه‌ای قابل استفاده مجدد از مجوزهای عملیات منبع است که می‌توان آن را برای منابع مشخص به اعضا اختصاص داد.", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index f48f640dad2f98..b633101ad7f932 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "مستقر نشده", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "در حال لغو استقرار", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ناشناخته", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "برنامه را از سرویس‌های خود از طریق HTTP فراخوانی کنید", "studio.accessPoint.description": "همه روش‌های دسترسی به این برنامه در محیط داخلی و هر محیط استقرار‌یافته.", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 4640a108c3aa13..576a5bf04791ff 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Source de Données", "settings.extension": "Extension", "settings.integrations": "Intégrations", + "settings.ipPolicies": "IP Policies", "settings.members": "Membres", "settings.permissionSet": "Ensemble d’autorisations", "settings.permissionSetDescription": "Configurez des ensembles d’autorisations à utiliser avec les apps et les bases de connaissances. Un ensemble d’autorisations est une collection réutilisable d’autorisations d’opérations sur les ressources pouvant être attribuée aux membres pour des ressources spécifiques.", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index 13518732df2b36..ffabe66308f0fe 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non déployé", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annulation du déploiement", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Inconnu", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Appelez l’application depuis vos services via HTTP", "studio.accessPoint.description": "Toutes les façons d’accéder à cette application dans l’environnement intégré et dans chaque environnement déployé.", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 275177d98f1408..6c8776292e77fe 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "डेटा स्रोत", "settings.extension": "Extension", "settings.integrations": "एकीकरण", + "settings.ipPolicies": "IP Policies", "settings.members": "सदस्य", "settings.permissionSet": "अनुमति सेट", "settings.permissionSetDescription": "ऐप्लिकेशन और नॉलेज बेस के साथ उपयोग के लिए अनुमति सेट कॉन्फ़िगर करें। अनुमति सेट संसाधन संचालन अनुमतियों का पुन: उपयोग योग्य संग्रह है, जिसे विशिष्ट संसाधनों के लिए सदस्यों को असाइन किया जा सकता है।", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index 38834822638125..059de2b575870f 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "डिप्लॉय नहीं किया गया", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "अनडिप्लॉय हो रहा है", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "अज्ञात", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "HTTP के ज़रिए अपनी सेवाओं से ऐप को कॉल करें", "studio.accessPoint.description": "बिल्ट-इन और हर डिप्लॉय किए गए एनवायरनमेंट में इस ऐप तक पहुँचने के सभी तरीके।", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 586dc35e203cd3..a66f00497a1a2b 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Sumber Data", "settings.extension": "Extension", "settings.integrations": "Integrasi", + "settings.ipPolicies": "IP Policies", "settings.members": "Anggota", "settings.permissionSet": "Kumpulan Izin", "settings.permissionSetDescription": "Konfigurasikan kumpulan izin untuk digunakan dengan aplikasi dan basis pengetahuan. Kumpulan izin adalah koleksi izin operasi sumber daya yang dapat digunakan kembali dan dapat ditetapkan kepada anggota untuk sumber daya tertentu.", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index d08ac1a0cad109..0397c16eb99d47 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Belum diterapkan", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Membatalkan penerapan", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Tidak diketahui", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Panggil aplikasi dari layanan Anda melalui HTTP", "studio.accessPoint.description": "Semua cara aplikasi ini dapat diakses di lingkungan Bawaan dan setiap lingkungan yang di-deploy.", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index eab7932c4e470b..bc18e0f8949dfa 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Fonte Dati", "settings.extension": "Extension", "settings.integrations": "Integrazioni", + "settings.ipPolicies": "IP Policies", "settings.members": "Membri", "settings.permissionSet": "Set di autorizzazioni", "settings.permissionSetDescription": "Configura set di autorizzazioni da usare con app e basi di conoscenza. Un set di autorizzazioni è una raccolta riutilizzabile di autorizzazioni per operazioni sulle risorse che può essere assegnata ai membri per risorse specifiche.", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index 5c014d678adccf..80e473a8fbee20 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non distribuito", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annullamento distribuzione", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Sconosciuto", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chiama l’app dai tuoi servizi tramite HTTP", "studio.accessPoint.description": "Tutti i modi per raggiungere questa app nell’ambiente integrato e in ogni ambiente distribuito.", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 364681fcde23a2..ac6c3fc597a813 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "データソース", "settings.extension": "拡張機能", "settings.integrations": "統合", + "settings.ipPolicies": "IP ポリシー", "settings.members": "メンバー", "settings.permissionSet": "権限セット", "settings.permissionSetDescription": "アプリとナレッジベースで使用する権限セットを設定します。権限セットは、特定のリソースに対してメンバーに割り当てられる、リソース操作権限の再利用可能なコレクションです。", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index 58cb3dc645b2e2..eadccd20e22cd2 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未デプロイ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "アンデプロイ中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "不明", + "studio.accessControl.addressCount": "{{count}} 件のアドレス", + "studio.accessControl.applyTo": "適用先", + "studio.accessControl.applyToHelp": "保護するアクセスポイントを選択します。", + "studio.accessControl.applyToHelpSelected": "このポリシーで保護するアクセスポイントを選択します。", + "studio.accessControl.createIpPolicy": "IP ポリシーを作成", + "studio.accessControl.emptyPoliciesDescription": "ポリシーは許可する IP アドレスの一覧です。作成してから、ここに戻って適用してください。", + "studio.accessControl.emptyPoliciesTitle": "このワークスペースにはまだ IP ポリシーがありません", "studio.accessControl.entryLabel": "アクセス制御", + "studio.accessControl.excluded": "対象外", + "studio.accessControl.ipPolicy": "IP ポリシー", + "studio.accessControl.lockoutWarning": "あなたの IP({{ip}})はこのポリシーに含まれていません。アクセスできなくなる可能性があります。", + "studio.accessControl.manageIpPolicies": "IP ポリシーを管理", + "studio.accessControl.notEnabled": "未有効", "studio.accessControl.paywallDescription": "信頼できる IP アドレスからのみ、このアプリにアクセスできるようにします。", "studio.accessControl.paywallTitle": "アクセス制御", + "studio.accessControl.policySummaryMany": "{{listed}} とさらに {{count}} 件のアドレスを許可", + "studio.accessControl.policySummaryNone": "許可するアドレスはありません", + "studio.accessControl.policySummaryOne": "{{address}} を許可", + "studio.accessControl.policySummaryTwo": "{{first}} と {{second}} を許可", "studio.accessControl.previewAppName": "コードコンパニオン", "studio.accessControl.previewCaption": "このアプリは、組織のネットワーク上でのみ利用できます。", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "IP アドレスで制限", + "studio.accessControl.saveAnyway": "保存する", + "studio.accessControl.saveWithoutOwnIp": "自分の IP を含めずに保存しますか?", + "studio.accessControl.selectAccessPoint": "保護するアクセスポイントを少なくとも 1 つ選択してください。", + "studio.accessControl.selectPolicy": "ポリシーを選択", "studio.accessControl.turnOn": "アクセス制御をオンにする", "studio.accessPoint.apiDescription": "HTTP 経由でアプリをサービスに統合", "studio.accessPoint.description": "このアプリにアクセスするすべての方法。アクセスポイントは実行中のバージョンのみに対応します。", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 31dfd475da1484..f732e86b7af301 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "데이터 소스", "settings.extension": "Extension", "settings.integrations": "통합", + "settings.ipPolicies": "IP Policies", "settings.members": "멤버", "settings.permissionSet": "권한 세트", "settings.permissionSetDescription": "앱과 지식 베이스에서 사용할 권한 세트를 구성합니다. 권한 세트는 특정 리소스에 대해 멤버에게 할당할 수 있는 재사용 가능한 리소스 작업 권한 모음입니다.", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index 8ed58ff22204e5..887b04e7958ed4 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "배포되지 않음", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "배포 취소 중", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "알 수 없음", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "서비스에서 HTTP로 앱 호출", "studio.accessPoint.description": "기본 제공 환경과 모든 배포 환경에서 이 앱에 접근하는 모든 방법입니다.", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index 55b0e58ecd2544..cb29d3a1f03aaa 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "ແຫຼ່ງຂໍ້ມູນ", "settings.extension": "ສ່ວນຂະຫຍາຍ", "settings.integrations": "ການເຊື່ອມຕໍ່", + "settings.ipPolicies": "IP Policies", "settings.members": "ສະມາຊິກ", "settings.permissionSet": "ຊຸດການອະນຸຍາດ", "settings.permissionSetDescription": "ຕັ້ງຄ່າຊຸດການອະນຸຍາດສຳລັບໃຊ້ກັບແອັບ ແລະ ຖານຄວາມຮູ້. ຊຸດການອະນຸຍາດແມ່ນການລວມເອົາສິດການດຳເນີນການຊັບພະຍາກອນທີ່ສາມາດນຳມາໃຊ້ຄືນໄດ້ ເຊິ່ງສາມາດມອບໝາຍໃຫ້ສະມາຊິກສຳລັບຊັບພະຍາກອນສະເພາະ.", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index 64f89ac803bf92..0b15582fa8253b 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ຍັງບໍ່ໄດ້ຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "ກຳລັງຖອນການຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ບໍ່ຮູ້ຈັກ", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "ເອີ້ນໃຊ້ແອັບຈາກບໍລິການຂອງທ່ານຜ່ານ HTTP", "studio.accessPoint.description": "ທຸກວິທີທີ່ສາມາດເຂົ້າເຖິງແອັບນີ້ໄດ້ໃນສະພາບແວດລ້ອມ Built-in ແລະທຸກສະພາບແວດລ້ອມທີ່ deploy ແລ້ວ.", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 52fb04cec72de0..168e7b91ac2b46 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Data Source", "settings.extension": "Extension", "settings.integrations": "Integrations", + "settings.ipPolicies": "IP Policies", "settings.members": "Members", "settings.permissionSet": "Set met rechten", "settings.permissionSetDescription": "Configureer sets met rechten voor gebruik met apps en kennisbanken. Een set met rechten is een herbruikbare verzameling rechten voor resourcebewerkingen die aan leden kan worden toegewezen voor specifieke resources.", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index a7a6ebb1376b91..8c20c4328289d4 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Niet geïmplementeerd", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Implementatie ongedaan maken", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Onbekend", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Roep de app via HTTP aan vanuit je services", "studio.accessPoint.description": "Alle manieren waarop deze app bereikbaar is in de ingebouwde omgeving en elke geïmplementeerde omgeving.", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 9068c36ff1d168..e89d9ea79ea4f4 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Źródło danych", "settings.extension": "Extension", "settings.integrations": "Integracje", + "settings.ipPolicies": "IP Policies", "settings.members": "Członkowie", "settings.permissionSet": "Zestaw uprawnień", "settings.permissionSetDescription": "Skonfiguruj zestawy uprawnień do użycia z aplikacjami i bazami wiedzy. Zestaw uprawnień to wielokrotnego użytku zbiór uprawnień do operacji na zasobach, który można przypisywać członkom dla konkretnych zasobów.", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index 058c2e3f2a6a26..2608b5a3b61d6a 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nie wdrożone", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Wycofywanie wdrożenia", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Nieznane", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Wywołuj aplikację z usług przez HTTP", "studio.accessPoint.description": "Wszystkie sposoby dostępu do tej aplikacji w środowisku wbudowanym i każdym wdrożonym środowisku.", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 2a603c397c5562..b73bbbc18f51de 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Fonte de dados", "settings.extension": "Extension", "settings.integrations": "Integrações", + "settings.ipPolicies": "IP Policies", "settings.members": "Membros", "settings.permissionSet": "Conjunto de permissões", "settings.permissionSetDescription": "Configure conjuntos de permissões para uso com apps e bases de conhecimento. Um conjunto de permissões é uma coleção reutilizável de permissões de operações de recursos que pode ser atribuída a membros para recursos específicos.", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index 6c5c72f6cc908b..021ec3fa5eabba 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Não implantado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Desimplantando", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconhecido", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chame o aplicativo a partir dos seus serviços via HTTP", "studio.accessPoint.description": "Todas as formas de acessar este aplicativo no ambiente integrado e em cada ambiente implantado.", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 18c8e7ac479937..9dfb86ca7fe519 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Sursă de date", "settings.extension": "Extension", "settings.integrations": "Integrări", + "settings.ipPolicies": "IP Policies", "settings.members": "Membri", "settings.permissionSet": "Set de permisiuni", "settings.permissionSetDescription": "Configurați seturi de permisiuni pentru utilizare cu aplicații și baze de cunoștințe. Un set de permisiuni este o colecție reutilizabilă de permisiuni pentru operațiuni asupra resurselor, care poate fi atribuită membrilor pentru resurse specifice.", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index 882dd859431ec2..2ee19c830d37e8 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Neimplementat", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Se anulează implementarea", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Necunoscut", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Apelează aplicația din serviciile tale prin HTTP", "studio.accessPoint.description": "Toate modalitățile prin care această aplicație poate fi accesată în mediul integrat și în fiecare mediu implementat.", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 2ed10ac7214a4e..9403297e621dfa 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Источник данных", "settings.extension": "Extension", "settings.integrations": "Интеграции", + "settings.ipPolicies": "IP Policies", "settings.members": "Участники", "settings.permissionSet": "Набор разрешений", "settings.permissionSetDescription": "Настройте наборы разрешений для использования с приложениями и базами знаний. Набор разрешений — это многократно используемая коллекция разрешений на операции с ресурсами, которую можно назначать участникам для конкретных ресурсов.", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index f27ef5a7ab1ddb..7e769e00386d76 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не развернуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Свертывание развертывания", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Неизвестно", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Вызывайте приложение из своих сервисов по HTTP", "studio.accessPoint.description": "Все способы доступа к приложению во встроенной среде и каждой развернутой среде.", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index b2d67f7400d4d5..1ab88849a8fd34 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Vir podatkov", "settings.extension": "Extension", "settings.integrations": "Integracije", + "settings.ipPolicies": "IP Policies", "settings.members": "Člani", "settings.permissionSet": "Nabor dovoljenj", "settings.permissionSetDescription": "Konfigurirajte nabore dovoljenj za uporabo z aplikacijami in bazami znanja. Nabor dovoljenj je ponovno uporabna zbirka dovoljenj za operacije nad viri, ki jo je mogoče dodeliti članom za določene vire.", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 7fc0994303dcce..9fca0190c102e7 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Ni uvedeno", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Razveljavljanje uvedbe", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Neznano", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Pokličite aplikacijo iz svojih storitev prek HTTP", "studio.accessPoint.description": "Vsi načini dostopa do te aplikacije v vgrajenem in vsakem uvedenem okolju.", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index ebe96553cb6e16..1174e5a458873f 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "แหล่งข้อมูล", "settings.extension": "Extension", "settings.integrations": "บูรณาการ", + "settings.ipPolicies": "IP Policies", "settings.members": "สมาชิก", "settings.permissionSet": "ชุดสิทธิ์", "settings.permissionSetDescription": "กำหนดค่าชุดสิทธิ์สำหรับใช้กับแอปและฐานความรู้ ชุดสิทธิ์คือชุดสิทธิ์การดำเนินการกับทรัพยากรที่นำกลับมาใช้ซ้ำได้ และสามารถกำหนดให้สมาชิกสำหรับทรัพยากรเฉพาะได้", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index 859fd6dcffbbca..aeb4dc29bb1e6e 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ยังไม่ได้ปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "กำลังยกเลิกการปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ไม่ทราบ", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "เรียกใช้แอปจากบริการของคุณผ่าน HTTP", "studio.accessPoint.description": "ทุกช่องทางที่เข้าถึงแอปนี้ได้ในสภาพแวดล้อม Built-in และทุกสภาพแวดล้อมที่ปรับใช้", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 1a979ec22ea5a4..07a1fd64e0bab1 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Veri Kaynağı", "settings.extension": "Extension", "settings.integrations": "Entegrasyonlar", + "settings.ipPolicies": "IP Policies", "settings.members": "Üyeler", "settings.permissionSet": "İzin Seti", "settings.permissionSetDescription": "Uygulamalar ve bilgi tabanlarıyla kullanılacak izin setlerini yapılandırın. İzin seti, belirli kaynaklar için üyelere atanabilen yeniden kullanılabilir kaynak işlem izinleri koleksiyonudur.", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index 3ae695d3abb284..9e7597e7a5caf7 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Dağıtılmadı", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Dağıtım geri alınıyor", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Bilinmiyor", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Uygulamayı hizmetlerinizden HTTP üzerinden çağırın", "studio.accessPoint.description": "Bu uygulamaya Yerleşik ortamda ve dağıtılan her ortamda erişmenin tüm yolları.", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index a97f8aacce31d8..7f64da9664ea0b 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Джерело даних", "settings.extension": "Extension", "settings.integrations": "Інтеграції", + "settings.ipPolicies": "IP Policies", "settings.members": "Учасники", "settings.permissionSet": "Набір дозволів", "settings.permissionSetDescription": "Налаштуйте набори дозволів для використання із застосунками та базами знань. Набір дозволів — це багаторазова колекція дозволів на операції з ресурсами, яку можна призначати учасникам для конкретних ресурсів.", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index 0c7fd802f464e1..795d0116aade5e 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не розгорнуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Скасування розгортання", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Невідомо", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Викликайте застосунок зі своїх сервісів через HTTP", "studio.accessPoint.description": "Усі способи доступу до застосунку у вбудованому та кожному розгорнутому середовищі.", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 300eb97cbbc1ee..7a1f224f56eb00 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "Nguồn dữ liệu", "settings.extension": "Extension", "settings.integrations": "Tích hợp", + "settings.ipPolicies": "IP Policies", "settings.members": "Thành viên", "settings.permissionSet": "Bộ quyền", "settings.permissionSetDescription": "Định cấu hình các bộ quyền để sử dụng với ứng dụng và cơ sở kiến thức. Bộ quyền là tập hợp có thể tái sử dụng các quyền thao tác trên tài nguyên, có thể được gán cho thành viên đối với các tài nguyên cụ thể.", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index ff8218441333f5..5fd4420a7304d4 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Chưa triển khai", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Đang hủy triển khai", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Không xác định", + "studio.accessControl.addressCount": "{{count}} addresses", + "studio.accessControl.applyTo": "Apply to", + "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", + "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "Excluded", + "studio.accessControl.ipPolicy": "IP Policy", + "studio.accessControl.lockoutWarning": "Your IP ({{ip}}) isn't in this policy. You may lose access.", + "studio.accessControl.manageIpPolicies": "Manage IP policies", + "studio.accessControl.notEnabled": "Not enabled", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "Allows {{listed}} and {{count}} more addresses", + "studio.accessControl.policySummaryNone": "Allows no addresses", + "studio.accessControl.policySummaryOne": "Allows {{address}}", + "studio.accessControl.policySummaryTwo": "Allows {{first}} and {{second}}", "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.saveAnyway": "Save anyway", + "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", + "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", + "studio.accessControl.selectPolicy": "Select a policy", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Gọi ứng dụng từ các dịch vụ của bạn qua HTTP", "studio.accessPoint.description": "Mọi cách có thể truy cập ứng dụng này trong môi trường tích hợp sẵn và mọi môi trường đã triển khai.", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 26552a33ee8795..c0994496b573b6 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "数据来源", "settings.extension": "扩展", "settings.integrations": "集成", + "settings.ipPolicies": "IP 策略", "settings.members": "成员", "settings.permissionSet": "权限集", "settings.permissionSetDescription": "配置可供应用和知识库使用的权限集。权限集是一组可复用的资源操作权限,可针对特定资源分配给成员。", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index d9f9d4bb0572dd..f8ff6a9170b1fd 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下线中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessControl.addressCount": "{{count}} 个地址", + "studio.accessControl.applyTo": "应用于", + "studio.accessControl.applyToHelp": "选择要保护的访问点。", + "studio.accessControl.applyToHelpSelected": "选择此策略要保护的访问点。", + "studio.accessControl.createIpPolicy": "创建 IP 策略", + "studio.accessControl.emptyPoliciesDescription": "策略是允许访问的 IP 地址列表。先创建一条,再回到这里应用。", + "studio.accessControl.emptyPoliciesTitle": "当前工作空间还没有 IP 策略", "studio.accessControl.entryLabel": "访问控制", + "studio.accessControl.excluded": "未覆盖", + "studio.accessControl.ipPolicy": "IP 策略", + "studio.accessControl.lockoutWarning": "你的 IP({{ip}})不在此策略中。你可能会失去访问权限。", + "studio.accessControl.manageIpPolicies": "管理 IP 策略", + "studio.accessControl.notEnabled": "未启用", "studio.accessControl.paywallDescription": "将此应用限制为仅允许你信任的 IP 地址访问。", "studio.accessControl.paywallTitle": "访问控制", + "studio.accessControl.policySummaryMany": "允许 {{listed}} 以及另外 {{count}} 个地址", + "studio.accessControl.policySummaryNone": "未允许任何地址", + "studio.accessControl.policySummaryOne": "允许 {{address}}", + "studio.accessControl.policySummaryTwo": "允许 {{first}} 和 {{second}}", "studio.accessControl.previewAppName": "代码助手", "studio.accessControl.previewCaption": "此应用仅允许在组织网络中访问。", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "按 IP 地址限制", + "studio.accessControl.saveAnyway": "仍要保存", + "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情况下保存?", + "studio.accessControl.selectAccessPoint": "请至少选择一个要保护的访问点。", + "studio.accessControl.selectPolicy": "选择策略", "studio.accessControl.turnOn": "开启访问控制", "studio.accessPoint.apiDescription": "通过 HTTP 将应用集成到你的服务中", "studio.accessPoint.description": "此应用的所有访问方式。访问点仅对应当前运行版本。", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 7b001d7afb5240..04beac4950952f 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -549,6 +549,7 @@ "settings.dataSource": "資料來源", "settings.extension": "擴充", "settings.integrations": "集成", + "settings.ipPolicies": "IP 策略", "settings.members": "成員", "settings.permissionSet": "權限集", "settings.permissionSetDescription": "配置可供應用和知識庫使用的權限集。權限集是一組可重複使用的資源操作權限,可針對特定資源分配給成員。", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index 97bd64423154ea..8e5298585efd81 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -33,12 +33,33 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下線中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessControl.addressCount": "{{count}} 個地址", + "studio.accessControl.applyTo": "套用至", + "studio.accessControl.applyToHelp": "選擇要保護的存取點。", + "studio.accessControl.applyToHelpSelected": "選擇此策略要保護的存取點。", + "studio.accessControl.createIpPolicy": "建立 IP 策略", + "studio.accessControl.emptyPoliciesDescription": "策略是允許存取的 IP 位址清單。先建立一條,再回到這裡套用。", + "studio.accessControl.emptyPoliciesTitle": "目前工作區還沒有 IP 策略", "studio.accessControl.entryLabel": "Access Control", + "studio.accessControl.excluded": "未涵蓋", + "studio.accessControl.ipPolicy": "IP 策略", + "studio.accessControl.lockoutWarning": "你的 IP({{ip}})不在此策略中。你可能會失去存取權限。", + "studio.accessControl.manageIpPolicies": "管理 IP 策略", + "studio.accessControl.notEnabled": "未啟用", "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.policySummaryMany": "允許 {{listed}} 以及另外 {{count}} 個地址", + "studio.accessControl.policySummaryNone": "未允許任何地址", + "studio.accessControl.policySummaryOne": "允許 {{address}}", + "studio.accessControl.policySummaryTwo": "允許 {{first}} 和 {{second}}", "studio.accessControl.previewAppName": "程式助手", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.restrictByIp": "依 IP 位址限制", + "studio.accessControl.saveAnyway": "仍要儲存", + "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情況下儲存?", + "studio.accessControl.selectAccessPoint": "請至少選擇一個要保護的存取點。", + "studio.accessControl.selectPolicy": "選擇策略", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "透過 HTTP 從你的服務呼叫應用程式", "studio.accessPoint.description": "應用程式在內建環境和每個已部署環境中的所有存取方式。", From 69684a3d1a3702a122674d6a1b8fa01a1e3943a3 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 8 Sep 2026 17:28:56 +0800 Subject: [PATCH 010/108] feat(web): add Access Control chip states for paid first-open Paid Cloud chips show Off, ON, n of m, or paused. Clicking Off opens L2; configured chips open L1. Sandbox still uses the PRO paywall. --- .../__tests__/chip-status.spec.ts | 101 +++++++++++ .../__tests__/config-panel.spec.tsx | 1 + .../access-control/__tests__/index.spec.tsx | 8 +- .../access-control/chip-affix.tsx | 60 +++++++ .../access-control/chip-status.ts | 69 ++++++++ .../app/access-point/access-control/draft.ts | 12 +- .../app/access-point/access-control/index.tsx | 161 +++++++++++------- .../access-control/status-panel.tsx | 83 ++++++++- web/i18n/ar-TN/deployments.json | 14 ++ web/i18n/de-DE/deployments.json | 14 ++ web/i18n/en-US/deployments.json | 14 ++ web/i18n/es-ES/deployments.json | 14 ++ web/i18n/fa-IR/deployments.json | 14 ++ web/i18n/fr-FR/deployments.json | 14 ++ web/i18n/hi-IN/deployments.json | 14 ++ web/i18n/id-ID/deployments.json | 14 ++ web/i18n/it-IT/deployments.json | 14 ++ web/i18n/ja-JP/deployments.json | 14 ++ web/i18n/ko-KR/deployments.json | 14 ++ web/i18n/lo-LA/deployments.json | 14 ++ web/i18n/nl-NL/deployments.json | 14 ++ web/i18n/pl-PL/deployments.json | 14 ++ web/i18n/pt-BR/deployments.json | 14 ++ web/i18n/ro-RO/deployments.json | 14 ++ web/i18n/ru-RU/deployments.json | 14 ++ web/i18n/sl-SI/deployments.json | 14 ++ web/i18n/th-TH/deployments.json | 14 ++ web/i18n/tr-TR/deployments.json | 14 ++ web/i18n/uk-UA/deployments.json | 14 ++ web/i18n/vi-VN/deployments.json | 14 ++ web/i18n/zh-Hans/deployments.json | 14 ++ web/i18n/zh-Hant/deployments.json | 14 ++ 32 files changed, 760 insertions(+), 71 deletions(-) create mode 100644 web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts create mode 100644 web/app/components/app/access-point/access-control/chip-affix.tsx create mode 100644 web/app/components/app/access-point/access-control/chip-status.ts diff --git a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts new file mode 100644 index 00000000000000..8fabb749713e17 --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts @@ -0,0 +1,101 @@ +import { getAccessControlChipState, getInServiceCoverage } from '../chip-status' + +const availability = { + webApp: true, + serviceApi: true, + mcp: true, + trigger: false, +} + +const assignment = { + policyId: 'internal-network', + policyName: 'Internal Network', + scopes: { + webApp: true, + serviceApi: true, + mcp: true, + trigger: false, + }, + enabled: true, +} + +describe('getInServiceCoverage', () => { + it('ignores unavailable access points in the denominator', () => { + expect( + getInServiceCoverage( + { webApp: true, serviceApi: true, mcp: false, trigger: true }, + availability, + ), + ).toEqual({ coveredCount: 2, inServiceCount: 3 }) + }) +}) + +describe('getAccessControlChipState', () => { + it('returns pro for Cloud sandbox', () => { + expect( + getAccessControlChipState({ + plan: 'sandbox', + assignment, + availability, + }).kind, + ).toBe('pro') + }) + + it('returns off when the app has never been configured', () => { + expect( + getAccessControlChipState({ + plan: 'professional', + assignment: null, + availability, + }), + ).toEqual({ kind: 'off', coveredCount: 0, inServiceCount: 0 }) + }) + + it('returns paused when a saved policy is not enforcing', () => { + expect( + getAccessControlChipState({ + plan: 'professional', + assignment: { ...assignment, enabled: false }, + availability, + }), + ).toEqual({ + kind: 'paused', + policyName: 'Internal Network', + coveredCount: 3, + inServiceCount: 3, + }) + }) + + it('returns on when every in-service access point is covered', () => { + expect( + getAccessControlChipState({ + plan: 'team', + assignment, + availability, + }), + ).toEqual({ + kind: 'on', + policyName: 'Internal Network', + coveredCount: 3, + inServiceCount: 3, + }) + }) + + it('returns partial when some in-service access points are excluded', () => { + expect( + getAccessControlChipState({ + plan: 'professional', + assignment: { + ...assignment, + scopes: { ...assignment.scopes, mcp: false }, + }, + availability, + }), + ).toEqual({ + kind: 'partial', + policyName: 'Internal Network', + coveredCount: 2, + inServiceCount: 3, + }) + }) +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index 80807b3982645b..a24e085e94d863 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -132,6 +132,7 @@ describe('AccessControlConfigPanel', () => { await user.click(await screen.findByRole('option', { name: /Internal Network/ })) await user.click(screen.getByRole('switch', { name: 'Web App' })) await user.click(screen.getByRole('switch', { name: 'Backend Service API' })) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) expect(screen.getByText('Select at least one access point to protect.')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 5c6ab09626aa6b..9502de6902e82e 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -20,6 +20,9 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.emptyPoliciesDescription': 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', + 'studio.accessControl.chipOff': 'Off', + 'studio.accessControl.chipOn': 'ON', + 'studio.accessControl.chipPartial': '{{n}} of {{m}}', 'studio.accessControl.entryLabel': 'Access Control', 'studio.accessControl.ipPolicy': 'IP Policy', 'studio.accessControl.notEnabled': 'Not enabled', @@ -29,6 +32,8 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.previewCaption': "This app is only available on your organization's network.", 'studio.accessControl.proBadge': 'PRO', + 'studio.accessControl.tooltipOff': 'Not set up', + 'studio.accessControl.tooltipPro': 'Access control requires the Pro plan', 'studio.accessControl.turnOn': 'Turn on Access Control', })) @@ -106,12 +111,13 @@ describe('AccessControlEntry', () => { }) it.each(['professional', 'team'] as const)( - 'renders the Access Control chip without a PRO badge on Cloud %s', + 'renders the unpaid-config Off chip on Cloud %s', (plan) => { renderEntry({ plan }) const chip = getChip() expect(chip).toBeInTheDocument() + expect(within(chip).getByText('Off')).toBeInTheDocument() expect(within(chip).queryByText('PRO')).not.toBeInTheDocument() }, ) diff --git a/web/app/components/app/access-point/access-control/chip-affix.tsx b/web/app/components/app/access-point/access-control/chip-affix.tsx new file mode 100644 index 00000000000000..842f72ea1c1d3d --- /dev/null +++ b/web/app/components/app/access-point/access-control/chip-affix.tsx @@ -0,0 +1,60 @@ +'use client' + +import type { AccessControlChipKind } from './chip-status' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { useTranslation } from 'react-i18next' +import PremiumBadge from '@/app/components/base/premium-badge' + +type AccessControlChipAffixProps = { + kind: AccessControlChipKind + coveredCount: number + inServiceCount: number +} + +export function AccessControlChipAffix({ + kind, + coveredCount, + inServiceCount, +}: AccessControlChipAffixProps) { + const { t } = useTranslation() + + if (kind === 'pro') { + return ( + + + + {t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' })} + + + ) + } + + const statusLabel = + kind === 'on' + ? t(($) => $['studio.accessControl.chipOn'], { ns: 'deployments' }) + : kind === 'partial' + ? t(($) => $['studio.accessControl.chipPartial'], { + ns: 'deployments', + n: coveredCount, + m: inServiceCount, + }) + : t(($) => $['studio.accessControl.chipOff'], { ns: 'deployments' }) + + return ( + <> + + + + + + {statusLabel} + + + ) +} diff --git a/web/app/components/app/access-point/access-control/chip-status.ts b/web/app/components/app/access-point/access-control/chip-status.ts new file mode 100644 index 00000000000000..afd3b87adb2626 --- /dev/null +++ b/web/app/components/app/access-point/access-control/chip-status.ts @@ -0,0 +1,69 @@ +import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' + +export type AccessControlAssignment = { + policyId: string + policyName: string + scopes: AccessControlDraft['scopes'] + enabled: boolean +} + +export type AccessControlChipKind = 'pro' | 'off' | 'paused' | 'on' | 'partial' + +export type AccessControlChipState = { + kind: AccessControlChipKind + coveredCount: number + inServiceCount: number + policyName?: string +} + +export function getInServiceCoverage( + scopes: Record, + availability: AccessControlScopeAvailability, +) { + const inService = ACCESS_POINT_ORDER.filter((scope) => availability[scope]) + const covered = inService.filter((scope) => scopes[scope]) + + return { + coveredCount: covered.length, + inServiceCount: inService.length, + } +} + +export function getAccessControlChipState({ + plan, + assignment, + availability, +}: { + plan: string | undefined + assignment: AccessControlAssignment | null + availability: AccessControlScopeAvailability +}): AccessControlChipState { + if (plan === 'sandbox') { + return { kind: 'pro', coveredCount: 0, inServiceCount: 0 } + } + + if (!assignment) { + return { kind: 'off', coveredCount: 0, inServiceCount: 0 } + } + + const coverage = getInServiceCoverage(assignment.scopes, availability) + + if (!assignment.enabled) { + return { + kind: 'paused', + policyName: assignment.policyName, + ...coverage, + } + } + + return { + kind: + coverage.inServiceCount > 0 && coverage.coveredCount === coverage.inServiceCount + ? 'on' + : 'partial', + policyName: assignment.policyName, + ...coverage, + } +} diff --git a/web/app/components/app/access-point/access-control/draft.ts b/web/app/components/app/access-point/access-control/draft.ts index c9eb0aac937e31..4f2d266551da0c 100644 --- a/web/app/components/app/access-point/access-control/draft.ts +++ b/web/app/components/app/access-point/access-control/draft.ts @@ -15,13 +15,15 @@ export type AccessControlDraft = { scopes: Record } -export const createDefaultAccessControlDraft = (): AccessControlDraft => ({ +export const createDefaultAccessControlDraft = ( + availability?: AccessControlScopeAvailability, +): AccessControlDraft => ({ selectedPolicyId: null, scopes: { - webApp: true, - serviceApi: true, - mcp: false, - trigger: false, + webApp: availability?.webApp ?? true, + serviceApi: availability?.serviceApi ?? true, + mcp: availability?.mcp ?? true, + trigger: availability?.trigger ?? false, }, }) diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index 8d67ea4e28ad78..24fc1f99f0143d 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -1,14 +1,15 @@ 'use client' -import type { AccessControlDraft } from './draft' +import type { AccessControlAssignment } from './chip-status' +import type { AccessControlDraft, AccessControlPolicy } from './draft' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import PremiumBadge from '@/app/components/base/premium-badge' import { settingsQueryParamName, settingsQueryParser, @@ -18,6 +19,8 @@ import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' import { useAppWorkflow } from '@/service/use-workflow' import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' +import { AccessControlChipAffix } from './chip-affix' +import { getAccessControlChipState } from './chip-status' import { AccessControlConfigPanel } from './config-panel' import { createDefaultAccessControlDraft, getAccessControlScopeAvailability } from './draft' import { AccessControlFreePaywall } from './free-paywall' @@ -44,22 +47,58 @@ export function AccessControlEntry() { const shouldFetchWorkflow = isPaid && Boolean(appInfo && isAdvancedApp(appInfo)) const { data: workflow } = useAppWorkflow(shouldFetchWorkflow && appInfo ? appInfo.id : '') const [open, setOpen] = useState(false) - const [view, setView] = useState<'config' | 'status'>('config') + const [view, setView] = useState<'config' | 'status'>('status') const [showBack, setShowBack] = useState(false) - const [draft, setDraft] = useState(createDefaultAccessControlDraft) + const [draft, setDraft] = useState(null) const [savedDraft, setSavedDraft] = useState(null) + const [enabled, setEnabled] = useState(true) if (deploymentEdition !== 'CLOUD' || (!isSandbox && !isPaid)) return null const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) - const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) const workflowState = appInfo ? getPublishedWorkflowState(appInfo, workflow) : undefined const availability = getAccessControlScopeAvailability({ mode: appInfo?.mode, hasTriggerNode: Boolean(workflowState?.hasTriggerNode), isUnpublished: Boolean(workflowState?.isUnpublished), }) - const policies = [] as const + const policies: readonly AccessControlPolicy[] = [] + const resolvedDraft = draft ?? savedDraft ?? createDefaultAccessControlDraft(availability) + const assignment: AccessControlAssignment | null = savedDraft?.selectedPolicyId + ? { + policyId: savedDraft.selectedPolicyId, + policyName: + policies.find((policy) => policy.id === savedDraft.selectedPolicyId)?.name ?? + savedDraft.selectedPolicyId, + scopes: savedDraft.scopes, + enabled, + } + : null + const chip = getAccessControlChipState({ plan, assignment, availability }) + const showPaywall = chip.kind === 'pro' + const showStatus = Boolean(assignment) && view === 'status' + + const tooltip = + chip.kind === 'pro' + ? t(($) => $['studio.accessControl.tooltipPro'], { ns: 'deployments' }) + : chip.kind === 'paused' + ? t(($) => $['studio.accessControl.tooltipPaused'], { + ns: 'deployments', + name: chip.policyName ?? '', + }) + : chip.kind === 'on' + ? t(($) => $['studio.accessControl.tooltipOn'], { + ns: 'deployments', + name: chip.policyName ?? '', + }) + : chip.kind === 'partial' + ? t(($) => $['studio.accessControl.tooltipPartial'], { + ns: 'deployments', + name: chip.policyName ?? '', + n: chip.coveredCount, + m: chip.inServiceCount, + }) + : t(($) => $['studio.accessControl.tooltipOff'], { ns: 'deployments' }) const handleTurnOn = () => { setShowPricingModal() @@ -67,13 +106,9 @@ export function AccessControlEntry() { if (gtag) gtag('event', 'click_upgrade_btn', { loc: 'access-control-paywall' }) } - const resetDraft = () => { - setDraft(savedDraft ?? createDefaultAccessControlDraft()) - } - const handleCancel = () => { - resetDraft() - if (savedDraft) { + setDraft(null) + if (assignment) { setView('status') setShowBack(false) return @@ -87,59 +122,65 @@ export function AccessControlEntry() { } const handleSave = () => { - setSavedDraft(draft) + setSavedDraft(resolvedDraft) + setEnabled(true) + setDraft(null) setView('status') setShowBack(false) } return ( - - - - {label} - {isSandbox && ( - - - {pro} - - )} - - - {isSandbox ? ( - - ) : view === 'status' && savedDraft ? ( - { - setDraft(savedDraft) - setShowBack(true) - setView('config') - }} + + + + } + > + + {label} + - ) : ( - - )} - - + + + {showPaywall ? ( + + ) : showStatus && savedDraft ? ( + { + setDraft(savedDraft) + setShowBack(true) + setView('config') + }} + /> + ) : ( + + )} + + + {tooltip} + ) } diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx index df183b9d749b9e..e887c5f583a2fe 100644 --- a/web/app/components/app/access-point/access-control/status-panel.tsx +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -6,14 +6,25 @@ import type { AccessControlScopeAvailability, } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { PopoverTitle } from '@langgenius/dify-ui/popover' import { Switch } from '@langgenius/dify-ui/switch' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { useStore as useAppStore } from '@/app/components/app/store' import AppIcon from '@/app/components/base/app-icon' +import { getInServiceCoverage } from './chip-status' import { isProtectableAccessPoint, splitPolicySummary } from './draft' const SCOPE_ICONS: Record, string> = { @@ -28,6 +39,7 @@ type AccessControlStatusPanelProps = { enabled: boolean availability: AccessControlScopeAvailability onEdit: () => void + onEnabledChange: (enabled: boolean) => void } export function AccessControlStatusPanel({ @@ -36,23 +48,35 @@ export function AccessControlStatusPanel({ enabled, availability, onEdit, + onEnabledChange, }: AccessControlStatusPanelProps) { const { t } = useTranslation() const appInfo = useAppStore((state) => state.appDetail) + const [confirmPause, setConfirmPause] = useState(false) const selectedPolicy = policies.find((policy) => policy.id === draft.selectedPolicyId) const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.addresses) : undefined + const coverage = getInServiceCoverage(draft.scopes, availability) const labels: Record = { webApp: t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }), serviceApi: t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }), mcp: t(($) => $['mcp.server.title'], { ns: 'tools' }), trigger: t(($) => $['settings.trigger'], { ns: 'common' }), } + const exposedNames = ACCESS_POINT_ORDER.filter( + (scope) => availability[scope] && draft.scopes[scope], + ).map((scope) => labels[scope]) + const policyName = selectedPolicy?.name return (
- {t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' })} + {policyName + ? t(($) => $['studio.accessControl.restrictedTo'], { + ns: 'deployments', + name: policyName, + }) + : t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' })}
+ + + +
+ + {t(($) => $['studio.accessControl.turnOffTitle'], { ns: 'deployments' })} + + + {t(($) => $['studio.accessControl.turnOffDescription'], { + ns: 'deployments', + points: exposedNames.join(', '), + })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + { + onEnabledChange(false) + setConfirmPause(false) + }} + > + {t(($) => $['studio.accessControl.turnOffConfirm'], { ns: 'deployments' })} + + +
+
) } diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index 6967b45226f9ed..47a0077a600473 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "استدعِ التطبيق من خدماتك عبر HTTP", "studio.accessPoint.description": "جميع طرق الوصول إلى هذا التطبيق في البيئة المدمجة وكل بيئة منشورة.", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index d8281bef9db2b2..e8aaa306986ab6 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Rufen Sie die App über HTTP aus Ihren Diensten auf", "studio.accessPoint.description": "Alle Möglichkeiten, diese App in der integrierten Umgebung und jeder bereitgestellten Umgebung zu erreichen.", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index ad64f218d884f1..40c2e37a7d3c61 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Integrate the app into your services over HTTP", "studio.accessPoint.description": "All the ways this app can be reached. Access points match the running version only.", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index d53482ed7d19cd..1b491ba695fa92 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Llama a la aplicación desde tus servicios a través de HTTP", "studio.accessPoint.description": "Todas las formas de acceder a esta aplicación en el entorno integrado y en cada entorno desplegado.", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index b633101ad7f932..3da73b07904998 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "برنامه را از سرویس‌های خود از طریق HTTP فراخوانی کنید", "studio.accessPoint.description": "همه روش‌های دسترسی به این برنامه در محیط داخلی و هر محیط استقرار‌یافته.", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index ffabe66308f0fe..876e0692c737c8 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Appelez l’application depuis vos services via HTTP", "studio.accessPoint.description": "Toutes les façons d’accéder à cette application dans l’environnement intégré et dans chaque environnement déployé.", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index 059de2b575870f..e65c5cc49ca364 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "HTTP के ज़रिए अपनी सेवाओं से ऐप को कॉल करें", "studio.accessPoint.description": "बिल्ट-इन और हर डिप्लॉय किए गए एनवायरनमेंट में इस ऐप तक पहुँचने के सभी तरीके।", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index 0397c16eb99d47..bc2150db7414b1 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Panggil aplikasi dari layanan Anda melalui HTTP", "studio.accessPoint.description": "Semua cara aplikasi ini dapat diakses di lingkungan Bawaan dan setiap lingkungan yang di-deploy.", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index 80e473a8fbee20..06617769cf28cc 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chiama l’app dai tuoi servizi tramite HTTP", "studio.accessPoint.description": "Tutti i modi per raggiungere questa app nell’ambiente integrato e in ogni ambiente distribuito.", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index eadccd20e22cd2..8caa284e94c549 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "適用先", "studio.accessControl.applyToHelp": "保護するアクセスポイントを選択します。", "studio.accessControl.applyToHelpSelected": "このポリシーで保護するアクセスポイントを選択します。", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "IP ポリシーを作成", "studio.accessControl.emptyPoliciesDescription": "ポリシーは許可する IP アドレスの一覧です。作成してから、ここに戻って適用してください。", "studio.accessControl.emptyPoliciesTitle": "このワークスペースにはまだ IP ポリシーがありません", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "コードコンパニオン", "studio.accessControl.previewCaption": "このアプリは、組織のネットワーク上でのみ利用できます。", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "稼働中のアクセスポイント {{count}} 件すべてを保護しています。", + "studio.accessControl.protectingPartial": "稼働中のアクセスポイント {{m}} 件中 {{n}} 件を保護しています。", "studio.accessControl.restrictByIp": "IP アドレスで制限", + "studio.accessControl.restrictedTo": "{{name}} に制限", "studio.accessControl.saveAnyway": "保存する", "studio.accessControl.saveWithoutOwnIp": "自分の IP を含めずに保存しますか?", "studio.accessControl.selectAccessPoint": "保護するアクセスポイントを少なくとも 1 つ選択してください。", "studio.accessControl.selectPolicy": "ポリシーを選択", + "studio.accessControl.tooltipOff": "未設定", + "studio.accessControl.tooltipOn": "{{name}}、すべてのアクセスポイント", + "studio.accessControl.tooltipPartial": "{{name}}、{{n}} / {{m}} のアクセスポイント", + "studio.accessControl.tooltipPaused": "一時停止 — {{name}} は設定済みですが適用されていません", + "studio.accessControl.tooltipPro": "アクセス制御には Pro プランが必要です", + "studio.accessControl.turnOffConfirm": "オフにする", + "studio.accessControl.turnOffDescription": "{{points}} は任意の IP から到達できるようになります。", + "studio.accessControl.turnOffTitle": "アクセス制御をオフにしますか?", "studio.accessControl.turnOn": "アクセス制御をオンにする", "studio.accessPoint.apiDescription": "HTTP 経由でアプリをサービスに統合", "studio.accessPoint.description": "このアプリにアクセスするすべての方法。アクセスポイントは実行中のバージョンのみに対応します。", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index 887b04e7958ed4..565a340e7a5833 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "서비스에서 HTTP로 앱 호출", "studio.accessPoint.description": "기본 제공 환경과 모든 배포 환경에서 이 앱에 접근하는 모든 방법입니다.", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index 0b15582fa8253b..14ff57cefa7a0b 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "ເອີ້ນໃຊ້ແອັບຈາກບໍລິການຂອງທ່ານຜ່ານ HTTP", "studio.accessPoint.description": "ທຸກວິທີທີ່ສາມາດເຂົ້າເຖິງແອັບນີ້ໄດ້ໃນສະພາບແວດລ້ອມ Built-in ແລະທຸກສະພາບແວດລ້ອມທີ່ deploy ແລ້ວ.", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index 8c20c4328289d4..352fb5e69d87a1 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Roep de app via HTTP aan vanuit je services", "studio.accessPoint.description": "Alle manieren waarop deze app bereikbaar is in de ingebouwde omgeving en elke geïmplementeerde omgeving.", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index 2608b5a3b61d6a..2d1e134e80a678 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Wywołuj aplikację z usług przez HTTP", "studio.accessPoint.description": "Wszystkie sposoby dostępu do tej aplikacji w środowisku wbudowanym i każdym wdrożonym środowisku.", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index 021ec3fa5eabba..2d80b823e9b166 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Chame o aplicativo a partir dos seus serviços via HTTP", "studio.accessPoint.description": "Todas as formas de acessar este aplicativo no ambiente integrado e em cada ambiente implantado.", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index 2ee19c830d37e8..41ee3dbd032c60 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Apelează aplicația din serviciile tale prin HTTP", "studio.accessPoint.description": "Toate modalitățile prin care această aplicație poate fi accesată în mediul integrat și în fiecare mediu implementat.", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index 7e769e00386d76..edb87e9bd767b4 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Вызывайте приложение из своих сервисов по HTTP", "studio.accessPoint.description": "Все способы доступа к приложению во встроенной среде и каждой развернутой среде.", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 9fca0190c102e7..8c73bb434a07ec 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Pokličite aplikacijo iz svojih storitev prek HTTP", "studio.accessPoint.description": "Vsi načini dostopa do te aplikacije v vgrajenem in vsakem uvedenem okolju.", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index aeb4dc29bb1e6e..78313160ac624a 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "เรียกใช้แอปจากบริการของคุณผ่าน HTTP", "studio.accessPoint.description": "ทุกช่องทางที่เข้าถึงแอปนี้ได้ในสภาพแวดล้อม Built-in และทุกสภาพแวดล้อมที่ปรับใช้", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index 9e7597e7a5caf7..3704c873ee6d6f 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Uygulamayı hizmetlerinizden HTTP üzerinden çağırın", "studio.accessPoint.description": "Bu uygulamaya Yerleşik ortamda ve dağıtılan her ortamda erişmenin tüm yolları.", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index 795d0116aade5e..eac5c4b1ed883a 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Викликайте застосунок зі своїх сервісів через HTTP", "studio.accessPoint.description": "Усі способи доступу до застосунку у вбудованому та кожному розгорнутому середовищі.", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index 5fd4420a7304d4..3080f70f9996d3 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "Apply to", "studio.accessControl.applyToHelp": "Choose which access points to protect.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "Code Companion", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "Protecting all {{count}} access points in service.", + "studio.accessControl.protectingPartial": "Protecting {{n}} of {{m}} access points in service.", "studio.accessControl.restrictByIp": "Restrict by IP address", + "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.tooltipOff": "Not set up", + "studio.accessControl.tooltipOn": "{{name}}, all access points", + "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", + "studio.accessControl.tooltipPro": "Access control requires the Pro plan", + "studio.accessControl.turnOffConfirm": "Turn off", + "studio.accessControl.turnOffDescription": "{{points}} will be reachable from any IP.", + "studio.accessControl.turnOffTitle": "Turn off access control?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "Gọi ứng dụng từ các dịch vụ của bạn qua HTTP", "studio.accessPoint.description": "Mọi cách có thể truy cập ứng dụng này trong môi trường tích hợp sẵn và mọi môi trường đã triển khai.", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index f8ff6a9170b1fd..7b8a36b31b546e 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "应用于", "studio.accessControl.applyToHelp": "选择要保护的访问点。", "studio.accessControl.applyToHelpSelected": "选择此策略要保护的访问点。", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "创建 IP 策略", "studio.accessControl.emptyPoliciesDescription": "策略是允许访问的 IP 地址列表。先创建一条,再回到这里应用。", "studio.accessControl.emptyPoliciesTitle": "当前工作空间还没有 IP 策略", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "代码助手", "studio.accessControl.previewCaption": "此应用仅允许在组织网络中访问。", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "正在保护全部 {{count}} 个在服务中的访问点。", + "studio.accessControl.protectingPartial": "正在保护 {{n}} / {{m}} 个在服务中的访问点。", "studio.accessControl.restrictByIp": "按 IP 地址限制", + "studio.accessControl.restrictedTo": "已限制为 {{name}}", "studio.accessControl.saveAnyway": "仍要保存", "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情况下保存?", "studio.accessControl.selectAccessPoint": "请至少选择一个要保护的访问点。", "studio.accessControl.selectPolicy": "选择策略", + "studio.accessControl.tooltipOff": "尚未配置", + "studio.accessControl.tooltipOn": "{{name}},覆盖全部访问点", + "studio.accessControl.tooltipPartial": "{{name}},{{n}} / {{m}} 个访问点", + "studio.accessControl.tooltipPaused": "已暂停 — {{name}} 已配置但未生效", + "studio.accessControl.tooltipPro": "访问控制需要 Pro 套餐", + "studio.accessControl.turnOffConfirm": "关闭", + "studio.accessControl.turnOffDescription": "{{points}} 将可被任意 IP 访问。", + "studio.accessControl.turnOffTitle": "关闭访问控制?", "studio.accessControl.turnOn": "开启访问控制", "studio.accessPoint.apiDescription": "通过 HTTP 将应用集成到你的服务中", "studio.accessPoint.description": "此应用的所有访问方式。访问点仅对应当前运行版本。", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index 8e5298585efd81..f865016900c01a 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -37,6 +37,9 @@ "studio.accessControl.applyTo": "套用至", "studio.accessControl.applyToHelp": "選擇要保護的存取點。", "studio.accessControl.applyToHelpSelected": "選擇此策略要保護的存取點。", + "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOn": "ON", + "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "建立 IP 策略", "studio.accessControl.emptyPoliciesDescription": "策略是允許存取的 IP 位址清單。先建立一條,再回到這裡套用。", "studio.accessControl.emptyPoliciesTitle": "目前工作區還沒有 IP 策略", @@ -55,11 +58,22 @@ "studio.accessControl.previewAppName": "程式助手", "studio.accessControl.previewCaption": "This app is only available on your organization's network.", "studio.accessControl.proBadge": "PRO", + "studio.accessControl.protectingAll": "正在保護全部 {{count}} 個服務中的存取點。", + "studio.accessControl.protectingPartial": "正在保護 {{n}} / {{m}} 個服務中的存取點。", "studio.accessControl.restrictByIp": "依 IP 位址限制", + "studio.accessControl.restrictedTo": "已限制為 {{name}}", "studio.accessControl.saveAnyway": "仍要儲存", "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情況下儲存?", "studio.accessControl.selectAccessPoint": "請至少選擇一個要保護的存取點。", "studio.accessControl.selectPolicy": "選擇策略", + "studio.accessControl.tooltipOff": "尚未設定", + "studio.accessControl.tooltipOn": "{{name}},涵蓋全部存取點", + "studio.accessControl.tooltipPartial": "{{name}},{{n}} / {{m}} 個存取點", + "studio.accessControl.tooltipPaused": "已暫停 — {{name}} 已設定但未生效", + "studio.accessControl.tooltipPro": "存取控制需要 Pro 方案", + "studio.accessControl.turnOffConfirm": "關閉", + "studio.accessControl.turnOffDescription": "{{points}} 將可被任意 IP 存取。", + "studio.accessControl.turnOffTitle": "關閉存取控制?", "studio.accessControl.turnOn": "Turn on Access Control", "studio.accessPoint.apiDescription": "透過 HTTP 從你的服務呼叫應用程式", "studio.accessPoint.description": "應用程式在內建環境和每個已部署環境中的所有存取方式。", From 7e886303c8896ada9be012c414932d9288d41238 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 9 Sep 2026 13:47:11 +0800 Subject: [PATCH 011/108] feat(web): add Settings IP Policy create dialog Add the New IP Policy dialog and empty list host. Allowlist rows validate IPv4, IPv6, and CIDR live, and Create stays disabled until the name and at least one valid entry are filled. --- .../ip-policies-page/__tests__/index.spec.tsx | 43 ++++++ .../__tests__/policy-dialog.spec.tsx | 113 ++++++++++++++ .../__tests__/validate-ip-entry.spec.ts | 88 +++++++++++ .../ip-policies-page/allowlist-field.tsx | 142 ++++++++++++++++++ .../ip-policies-page/allowlist-row.ts | 8 + .../ip-policies-page/index.tsx | 36 ++++- .../ip-policies-page/policy-dialog.tsx | 118 +++++++++++++++ .../ip-policies-page/validate-ip-entry.ts | 117 +++++++++++++++ web/i18n/ar-TN/common.json | 20 +++ web/i18n/de-DE/common.json | 20 +++ web/i18n/en-US/common.json | 20 +++ web/i18n/es-ES/common.json | 20 +++ web/i18n/fa-IR/common.json | 20 +++ web/i18n/fr-FR/common.json | 20 +++ web/i18n/hi-IN/common.json | 20 +++ web/i18n/id-ID/common.json | 20 +++ web/i18n/it-IT/common.json | 20 +++ web/i18n/ja-JP/common.json | 20 +++ web/i18n/ko-KR/common.json | 20 +++ web/i18n/lo-LA/common.json | 20 +++ web/i18n/nl-NL/common.json | 20 +++ web/i18n/pl-PL/common.json | 20 +++ web/i18n/pt-BR/common.json | 20 +++ web/i18n/ro-RO/common.json | 20 +++ web/i18n/ru-RU/common.json | 20 +++ web/i18n/sl-SI/common.json | 20 +++ web/i18n/th-TH/common.json | 20 +++ web/i18n/tr-TR/common.json | 20 +++ web/i18n/uk-UA/common.json | 20 +++ web/i18n/vi-VN/common.json | 20 +++ web/i18n/zh-Hans/common.json | 20 +++ web/i18n/zh-Hant/common.json | 20 +++ 32 files changed, 1138 insertions(+), 7 deletions(-) create mode 100644 web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts create mode 100644 web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/allowlist-row.ts create mode 100644 web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx new file mode 100644 index 00000000000000..6ca76bf5378d5d --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -0,0 +1,43 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { render } from '@/test/console/render' +import IpPoliciesPage from '..' + +const translations = vi.hoisted(() => ({ + 'operation.cancel': 'Cancel', + 'operation.close': 'Close', + 'settings.ipPolicies': 'IP Policies', + 'settings.ipPoliciesDescription': + 'Reusable rules that control which IP addresses or ranges can access your apps', + 'settings.ipPolicyAddEntry': 'Add', + 'settings.ipPolicyAllowlist': 'Allowlist', + 'settings.ipPolicyAllowlistHelp': + 'Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.', + 'settings.ipPolicyCreate': 'Create', + 'settings.ipPolicyDialogDescription': + 'Specify which IP addresses or ranges can access your apps.', + 'settings.ipPolicyName': 'Name', + 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', + 'settings.ipPolicyNewTitle': 'New IP Policy', + 'settings.ipPolicyRemoveEntry': 'Remove entry', + 'studio.accessControl.emptyPoliciesDescription': + 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', + 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock(translations) +}) + +describe('IpPoliciesPage', () => { + it('opens the new policy dialog from Add', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add' })) + expect(screen.getByRole('heading', { name: 'New IP Policy' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx new file mode 100644 index 00000000000000..a1388915e35a57 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx @@ -0,0 +1,113 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { render } from '@/test/console/render' +import { IpPolicyDialog } from '../policy-dialog' + +const translations = vi.hoisted(() => ({ + 'operation.cancel': 'Cancel', + 'operation.close': 'Close', + 'operation.save': 'Save', + 'settings.ipPolicyAddCurrentIp': 'Add it', + 'settings.ipPolicyAddEntry': 'Add', + 'settings.ipPolicyAllowlist': 'Allowlist', + 'settings.ipPolicyAllowlistHelp': + 'Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.', + 'settings.ipPolicyCreate': 'Create', + 'settings.ipPolicyCurrentIp': 'Your current IP is {{ip}}', + 'settings.ipPolicyDialogDescription': + 'Specify which IP addresses or ranges can access your apps.', + 'settings.ipPolicyEntryOctetRange': 'Each octet must be 0–255', + 'settings.ipPolicyName': 'Name', + 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', + 'settings.ipPolicyNewTitle': 'New IP Policy', + 'settings.ipPolicyRemoveEntry': 'Remove entry', +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock(translations) +}) + +function DialogHarness({ + currentIp, + onSubmit = vi.fn(), +}: { + currentIp?: string + onSubmit?: (payload: { name: string; addresses: string[] }) => void +}) { + const [open, setOpen] = useState(true) + return ( + + ) +} + +describe('IpPolicyDialog', () => { + it('keeps Create disabled until the name and a valid entry are filled', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Internal Network') + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled() + }) + + it('shows a live error and keeps Create disabled for an invalid octet', async () => { + const user = userEvent.setup() + render() + + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Internal Network') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '203.0.113.999') + + expect(screen.getByText('Each octet must be 0–255')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + }) + + it('keeps a single empty row when the last entry is removed', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Remove entry' })) + + expect(screen.getByPlaceholderText('10.0.0.0/8')).toHaveValue('') + expect(screen.getAllByRole('button', { name: 'Remove entry' })).toHaveLength(1) + }) + + it('adds the current IP into the first empty row', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByText('Your current IP is 203.0.113.42')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add it' })) + expect(screen.getByPlaceholderText('10.0.0.0/8')).toHaveValue('203.0.113.42') + }) + + it('submits trimmed name and addresses', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Internal Network') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + await user.click(screen.getByRole('button', { name: 'Add' })) + const rows = screen.getAllByPlaceholderText('10.0.0.0/8') + expect(rows).toHaveLength(2) + await user.type(rows[1] as HTMLElement, '203.0.113.42') + await user.click(screen.getByRole('button', { name: 'Create' })) + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Internal Network', + addresses: ['10.0.0.0/8', '203.0.113.42'], + }) + }) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts new file mode 100644 index 00000000000000..80740c18f725f2 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts @@ -0,0 +1,88 @@ +import { canSubmitIpPolicy, collectIpAddresses, validateIpEntry } from '../validate-ip-entry' + +describe('validateIpEntry', () => { + it('treats blank input as empty, not invalid', () => { + expect(validateIpEntry('')).toEqual({ kind: 'empty' }) + expect(validateIpEntry(' ')).toEqual({ kind: 'empty' }) + }) + + it('accepts IPv4 addresses, IPv6 addresses, and CIDR ranges', () => { + expect(validateIpEntry('10.0.0.0/8')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('203.0.113.42')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('0.0.0.0/0')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('2001:db8:1f4a:2c::5')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('3fff:100::/32')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('::1')).toEqual({ kind: 'valid' }) + expect(validateIpEntry('2001:db8::1')).toEqual({ kind: 'valid' }) + }) + + it('rejects more than one slash', () => { + expect(validateIpEntry('10.0.0.0/8/16')).toEqual({ + kind: 'invalid', + code: 'multipleSlashes', + }) + }) + + it('rejects IPv4 leading zeros', () => { + expect(validateIpEntry('192.168.01.1')).toEqual({ kind: 'invalid', code: 'leadingZeros' }) + }) + + it('rejects IPv4 octets outside 0–255', () => { + expect(validateIpEntry('256.100.50.25')).toEqual({ kind: 'invalid', code: 'octetRange' }) + expect(validateIpEntry('203.0.113.999')).toEqual({ kind: 'invalid', code: 'octetRange' }) + }) + + it('rejects a non-numeric prefix length', () => { + expect(validateIpEntry('10.0.0.0/ab')).toEqual({ kind: 'invalid', code: 'prefixNotNumber' }) + expect(validateIpEntry('2001:db8::/x')).toEqual({ kind: 'invalid', code: 'prefixNotNumber' }) + }) + + it('rejects prefix lengths outside the address family range', () => { + expect(validateIpEntry('192.168.1.0/33')).toEqual({ + kind: 'invalid', + code: 'prefixRange', + max: 32, + }) + expect(validateIpEntry('3fff:100::/129')).toEqual({ + kind: 'invalid', + code: 'prefixRange', + max: 128, + }) + }) + + it('rejects invalid IPv6 addresses', () => { + expect(validateIpEntry('gggg::1')).toEqual({ kind: 'invalid', code: 'invalidIpv6' }) + expect(validateIpEntry('2001:db8:::1')).toEqual({ kind: 'invalid', code: 'invalidIpv6' }) + }) + + it('rejects ranges, wildcards, and hostnames', () => { + expect(validateIpEntry('192.168.1.1 - 192.168.1.50')).toEqual({ + kind: 'invalid', + code: 'unsupported', + }) + expect(validateIpEntry('10.0.0.*')).toEqual({ kind: 'invalid', code: 'unsupported' }) + expect(validateIpEntry('office.example.com')).toEqual({ kind: 'invalid', code: 'unsupported' }) + }) +}) + +describe('canSubmitIpPolicy', () => { + it('requires a name and at least one valid entry', () => { + expect(canSubmitIpPolicy('', ['10.0.0.0/8'])).toBe(false) + expect(canSubmitIpPolicy('Office', [''])).toBe(false) + expect(canSubmitIpPolicy('Office', ['10.0.0.0/8'])).toBe(true) + expect(canSubmitIpPolicy('Office', ['10.0.0.0/8', ''])).toBe(true) + }) + + it('blocks submit when any non-empty entry is invalid', () => { + expect(canSubmitIpPolicy('Office', ['10.0.0.0/8', '203.0.113.999'])).toBe(false) + }) +}) + +describe('collectIpAddresses', () => { + it('drops blank rows', () => { + expect(collectIpAddresses([' 10.0.0.0/8 ', '', '203.0.113.42'])).toEqual([ + '10.0.0.0/8', + '203.0.113.42', + ]) + }) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx new file mode 100644 index 00000000000000..374a734812bfe6 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx @@ -0,0 +1,142 @@ +'use client' + +import type { AllowlistRow } from './allowlist-row' +import type { IpEntryErrorCode } from './validate-ip-entry' +import { Button } from '@langgenius/dify-ui/button' +import { Field, FieldDescription, FieldError, FieldLabel } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Input } from '@langgenius/dify-ui/input' +import { useTranslation } from 'react-i18next' +import { createAllowlistRow } from './allowlist-row' +import { validateIpEntry } from './validate-ip-entry' + +type AllowlistFieldProps = { + entries: AllowlistRow[] + currentIp?: string + onEntriesChange: (entries: AllowlistRow[]) => void +} + +export function AllowlistField({ entries, currentIp, onEntriesChange }: AllowlistFieldProps) { + const { t } = useTranslation() + + const errorMessage = (code: IpEntryErrorCode, max?: 32 | 128) => { + switch (code) { + case 'multipleSlashes': + return t(($) => $['settings.ipPolicyEntryMultipleSlashes'], { ns: 'common' }) + case 'leadingZeros': + return t(($) => $['settings.ipPolicyEntryLeadingZeros'], { ns: 'common' }) + case 'octetRange': + return t(($) => $['settings.ipPolicyEntryOctetRange'], { ns: 'common' }) + case 'prefixNotNumber': + return t(($) => $['settings.ipPolicyEntryPrefixNotNumber'], { ns: 'common' }) + case 'prefixRange': + return t(($) => $['settings.ipPolicyEntryPrefixRange'], { ns: 'common', max }) + case 'invalidIpv6': + return t(($) => $['settings.ipPolicyEntryInvalidIpv6'], { ns: 'common' }) + default: + return t(($) => $['settings.ipPolicyEntryUnsupported'], { ns: 'common' }) + } + } + + const updateEntry = (id: string, value: string) => { + onEntriesChange(entries.map((entry) => (entry.id === id ? { ...entry, value } : entry))) + } + + const removeEntry = (id: string) => { + if (entries.length === 1) { + const row = entries[0] + if (!row) return + onEntriesChange([{ ...row, value: '' }]) + return + } + onEntriesChange(entries.filter((entry) => entry.id !== id)) + } + + const addCurrentIp = () => { + if (!currentIp) return + if (entries.some((entry) => entry.value.trim() === currentIp)) return + const emptyRow = entries.find((entry) => entry.value.trim() === '') + if (emptyRow) { + updateEntry(emptyRow.id, currentIp) + return + } + onEntriesChange([...entries, createAllowlistRow(currentIp)]) + } + + return ( + +
+ {t(($) => $['settings.ipPolicyAllowlist'], { ns: 'common' })} + + {t(($) => $['settings.ipPolicyAllowlistHelp'], { ns: 'common' })} + +
+ +
+ {entries.map((entry) => { + const result = validateIpEntry(entry.value) + const invalid = result.kind === 'invalid' + + return ( + +
+
+ $['settings.ipPolicyAllowlist'], { ns: 'common' })} + className={invalid ? 'pr-8' : undefined} + onChange={(event) => updateEntry(entry.id, event.currentTarget.value)} + /> + {invalid && ( + + )} +
+ $['settings.ipPolicyRemoveEntry'], { ns: 'common' })} + onClick={() => removeEntry(entry.id)} + > + + +
+ {invalid && ( + + {errorMessage(result.code, result.max)} + + )} +
+ ) + })} +
+ + + + {currentIp && ( +

+ {t(($) => $['settings.ipPolicyCurrentIp'], { ns: 'common', ip: currentIp })} + +

+ )} +
+ ) +} diff --git a/web/app/components/header/account-setting/ip-policies-page/allowlist-row.ts b/web/app/components/header/account-setting/ip-policies-page/allowlist-row.ts new file mode 100644 index 00000000000000..1fd9dbee6dc7e6 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/allowlist-row.ts @@ -0,0 +1,8 @@ +export type AllowlistRow = { + id: string + value: string +} + +export function createAllowlistRow(value = ''): AllowlistRow { + return { id: crypto.randomUUID(), value } +} diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index 4adafc1ca57171..2f9a62ae7ff375 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -1,18 +1,40 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' +import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { IpPolicyDialog } from './policy-dialog' export default function IpPoliciesPage() { const { t } = useTranslation() + const [dialogOpen, setDialogOpen] = useState(false) return ( -
-

- {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} -

-

- {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} -

+
+
+
+

+ {t(($) => $['settings.ipPolicies'], { ns: 'common' })} +

+

+ {t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' })} +

+
+ +
+ +
+

+ {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} +

+

+ {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} +

+
+ + {dialogOpen && }
) } diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx new file mode 100644 index 00000000000000..db0a0c3dd683b2 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -0,0 +1,118 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { Field, FieldLabel } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Input } from '@langgenius/dify-ui/input' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AllowlistField } from './allowlist-field' +import { createAllowlistRow } from './allowlist-row' +import { canSubmitIpPolicy, collectIpAddresses } from './validate-ip-entry' + +export type IpPolicyDialogSubmit = { + name: string + addresses: string[] +} + +type IpPolicyDialogProps = { + open: boolean + mode: 'create' | 'edit' + currentIp?: string + initialName?: string + initialEntries?: readonly string[] + onOpenChange: (open: boolean) => void + onSubmit?: (payload: IpPolicyDialogSubmit) => void +} + +export function IpPolicyDialog({ + open, + mode, + currentIp, + initialName = '', + initialEntries, + onOpenChange, + onSubmit, +}: IpPolicyDialogProps) { + const { t } = useTranslation() + const [name, setName] = useState(initialName) + const [entries, setEntries] = useState(() => + initialEntries && initialEntries.length > 0 + ? initialEntries.map((value) => createAllowlistRow(value)) + : [createAllowlistRow()], + ) + + const entryValues = entries.map((entry) => entry.value) + const canSubmit = canSubmitIpPolicy(name, entryValues) + + return ( + + +
+
+ + {mode === 'edit' + ? t(($) => $['settings.ipPolicyEditTitle'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyNewTitle'], { ns: 'common' })} + + + {t(($) => $['settings.ipPolicyDialogDescription'], { ns: 'common' })} + +
+ $['operation.close'], { ns: 'common' })} + > + + + } + /> +
+ +
{ + event.preventDefault() + if (!canSubmit) return + onSubmit?.({ + name: name.trim(), + addresses: collectIpAddresses(entryValues), + }) + }} + > + + {t(($) => $['settings.ipPolicyName'], { ns: 'common' })} + $['settings.ipPolicyNamePlaceholder'], { ns: 'common' })} + onChange={(event) => setName(event.currentTarget.value)} + /> + + + + +
+ + +
+ +
+
+ ) +} diff --git a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts new file mode 100644 index 00000000000000..c1421076974a7a --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts @@ -0,0 +1,117 @@ +export type IpEntryErrorCode = + | 'multipleSlashes' + | 'leadingZeros' + | 'octetRange' + | 'prefixNotNumber' + | 'prefixRange' + | 'invalidIpv6' + | 'unsupported' + +export type IpEntryValidation = + | { kind: 'empty' } + | { kind: 'valid' } + | { kind: 'invalid'; code: IpEntryErrorCode; max?: 32 | 128 } + +function isHexGroup(group: string) { + return /^[0-9a-f]{1,4}$/i.test(group) +} + +function parsePrefix(prefix: string): { kind: 'ok'; value: number } | { kind: 'notNumber' } { + if (!/^\d+$/.test(prefix)) return { kind: 'notNumber' } + return { kind: 'ok', value: Number(prefix) } +} + +function parseIpv4(address: string): 'valid' | 'leadingZeros' | 'octetRange' | 'unsupported' { + const parts = address.split('.') + if (parts.length !== 4) return 'unsupported' + + for (const part of parts) { + if (part === '') return 'unsupported' + if (!/^\d+$/.test(part)) return 'unsupported' + if (part.length > 1 && part.startsWith('0')) return 'leadingZeros' + const octet = Number(part) + if (octet > 255) return 'octetRange' + } + + return 'valid' +} + +function parseIpv6(address: string): boolean { + if (address.includes('.')) return false + + const sides = address.split('::') + if (sides.length > 2) return false + + if (sides.length === 2) { + const leftSide = sides[0] ?? '' + const rightSide = sides[1] ?? '' + const left = leftSide === '' ? [] : leftSide.split(':') + const right = rightSide === '' ? [] : rightSide.split(':') + if (left.some((group) => !isHexGroup(group)) || right.some((group) => !isHexGroup(group))) + return false + return left.length + right.length < 8 + } + + const groups = address.split(':') + return groups.length === 8 && groups.every(isHexGroup) +} + +export function validateIpEntry(raw: string): IpEntryValidation { + const value = raw.trim() + if (value === '') return { kind: 'empty' } + + const slashCount = (value.match(/\//g) ?? []).length + if (slashCount > 1) return { kind: 'invalid', code: 'multipleSlashes' } + + const slashIndex = value.indexOf('/') + const address = slashIndex === -1 ? value : value.slice(0, slashIndex) + const prefix = slashIndex === -1 ? undefined : value.slice(slashIndex + 1) + + if (address.includes(':')) { + if (prefix !== undefined) { + const parsed = parsePrefix(prefix) + if (parsed.kind === 'notNumber') return { kind: 'invalid', code: 'prefixNotNumber' } + if (parsed.value < 0 || parsed.value > 128) + return { kind: 'invalid', code: 'prefixRange', max: 128 } + } + if (!parseIpv6(address)) return { kind: 'invalid', code: 'invalidIpv6' } + return { kind: 'valid' } + } + + if (address.includes('.')) { + const ipv4 = parseIpv4(address) + if (ipv4 === 'leadingZeros') return { kind: 'invalid', code: 'leadingZeros' } + if (ipv4 === 'octetRange') return { kind: 'invalid', code: 'octetRange' } + if (ipv4 === 'unsupported') return { kind: 'invalid', code: 'unsupported' } + if (prefix !== undefined) { + const parsed = parsePrefix(prefix) + if (parsed.kind === 'notNumber') return { kind: 'invalid', code: 'prefixNotNumber' } + if (parsed.value < 0 || parsed.value > 32) + return { kind: 'invalid', code: 'prefixRange', max: 32 } + } + return { kind: 'valid' } + } + + if (prefix !== undefined) { + const parsed = parsePrefix(prefix) + if (parsed.kind === 'notNumber') return { kind: 'invalid', code: 'prefixNotNumber' } + } + + return { kind: 'invalid', code: 'unsupported' } +} + +export function canSubmitIpPolicy(name: string, entries: readonly string[]) { + if (name.trim() === '') return false + + const trimmed = entries.map((entry) => entry.trim()) + if (trimmed.every((entry) => entry === '')) return false + + return trimmed.every((entry) => { + const result = validateIpEntry(entry) + return result.kind === 'empty' || result.kind === 'valid' + }) +} + +export function collectIpAddresses(entries: readonly string[]) { + return entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0) +} diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index 974cd883965779..3d5b5a13e6c20f 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "التكاملات", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "الأعضاء", "settings.permissionSet": "مجموعة الأذونات", "settings.permissionSetDescription": "قم بتكوين مجموعات الأذونات لاستخدامها مع التطبيقات وقواعد المعرفة. مجموعة الأذونات هي مجموعة قابلة لإعادة الاستخدام من أذونات عمليات الموارد يمكن تعيينها للأعضاء لموارد محددة.", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 8484f772532ef6..6fd316a31882f6 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrationen", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Mitglieder", "settings.permissionSet": "Berechtigungssatz", "settings.permissionSetDescription": "Konfigurieren Sie Berechtigungssätze zur Verwendung mit Apps und Wissensdatenbanken. Ein Berechtigungssatz ist eine wiederverwendbare Sammlung von Berechtigungen für Ressourcenoperationen, die Mitgliedern für bestimmte Ressourcen zugewiesen werden kann.", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 6065af40b840fa..7c2804eec8b2da 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrations", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Members", "settings.permissionSet": "Permission Set", "settings.permissionSetDescription": "Configure permission sets for use with applications and knowledge bases. A permission set is a reusable collection of resource operation permissions that can be assigned to members for specific resources.", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 2404b986388404..6aab6fad4ee681 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integraciones", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Miembros", "settings.permissionSet": "Conjunto de permisos", "settings.permissionSetDescription": "Configura conjuntos de permisos para usarlos con aplicaciones y bases de conocimiento. Un conjunto de permisos es una colección reutilizable de permisos de operaciones sobre recursos que se puede asignar a miembros para recursos específicos.", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 3ee10e59de2171..cdfa0e1211c787 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "ادغام‌ها", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "اعضا", "settings.permissionSet": "مجموعه مجوزها", "settings.permissionSetDescription": "مجموعه‌های مجوز را برای استفاده با برنامه‌ها و پایگاه‌های دانش پیکربندی کنید. مجموعه مجوز، مجموعه‌ای قابل استفاده مجدد از مجوزهای عملیات منبع است که می‌توان آن را برای منابع مشخص به اعضا اختصاص داد.", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 576a5bf04791ff..f2770a89b9c477 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Intégrations", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Membres", "settings.permissionSet": "Ensemble d’autorisations", "settings.permissionSetDescription": "Configurez des ensembles d’autorisations à utiliser avec les apps et les bases de connaissances. Un ensemble d’autorisations est une collection réutilisable d’autorisations d’opérations sur les ressources pouvant être attribuée aux membres pour des ressources spécifiques.", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 6c8776292e77fe..5630c67ec4cbd0 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "एकीकरण", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "सदस्य", "settings.permissionSet": "अनुमति सेट", "settings.permissionSetDescription": "ऐप्लिकेशन और नॉलेज बेस के साथ उपयोग के लिए अनुमति सेट कॉन्फ़िगर करें। अनुमति सेट संसाधन संचालन अनुमतियों का पुन: उपयोग योग्य संग्रह है, जिसे विशिष्ट संसाधनों के लिए सदस्यों को असाइन किया जा सकता है।", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index a66f00497a1a2b..b86411709bae44 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrasi", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Anggota", "settings.permissionSet": "Kumpulan Izin", "settings.permissionSetDescription": "Konfigurasikan kumpulan izin untuk digunakan dengan aplikasi dan basis pengetahuan. Kumpulan izin adalah koleksi izin operasi sumber daya yang dapat digunakan kembali dan dapat ditetapkan kepada anggota untuk sumber daya tertentu.", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index bc18e0f8949dfa..799c52939a924e 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrazioni", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Membri", "settings.permissionSet": "Set di autorizzazioni", "settings.permissionSetDescription": "Configura set di autorizzazioni da usare con app e basi di conoscenza. Un set di autorizzazioni è una raccolta riutilizzabile di autorizzazioni per operazioni sulle risorse che può essere assegnata ai membri per risorse specifiche.", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index ac6c3fc597a813..5d1c6f6917d9ca 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -550,6 +550,26 @@ "settings.extension": "拡張機能", "settings.integrations": "統合", "settings.ipPolicies": "IP ポリシー", + "settings.ipPoliciesDescription": "アクセスを許可する IP アドレスまたは範囲を制御する、再利用可能なルール", + "settings.ipPolicyAddCurrentIp": "追加する", + "settings.ipPolicyAddEntry": "追加", + "settings.ipPolicyAllowlist": "許可リスト", + "settings.ipPolicyAllowlistHelp": "単一のアドレス(203.0.113.42)または CIDR 範囲(10.0.0.0/8)を入力できます。IPv4 と IPv6 の両方に対応しています。", + "settings.ipPolicyCreate": "作成", + "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", + "settings.ipPolicyDialogDescription": "アクセスを許可する IP アドレスまたは範囲を指定します。", + "settings.ipPolicyEditTitle": "IP ポリシーを編集", + "settings.ipPolicyEntryInvalidIpv6": "有効な IPv6 アドレスではありません", + "settings.ipPolicyEntryLeadingZeros": "先頭のゼロは使用できません", + "settings.ipPolicyEntryMultipleSlashes": "「/」は 1 つだけ使用できます", + "settings.ipPolicyEntryOctetRange": "各オクテットは 0〜255 の範囲で指定してください", + "settings.ipPolicyEntryPrefixNotNumber": "プレフィックス長は数値で指定してください", + "settings.ipPolicyEntryPrefixRange": "プレフィックス長は 0〜{{max}} の範囲で指定してください", + "settings.ipPolicyEntryUnsupported": "IPv4 / IPv6 アドレスと CIDR 範囲のみサポートしています", + "settings.ipPolicyName": "名前", + "settings.ipPolicyNamePlaceholder": "例:社内ネットワーク", + "settings.ipPolicyNewTitle": "IP ポリシーを作成", + "settings.ipPolicyRemoveEntry": "エントリを削除", "settings.members": "メンバー", "settings.permissionSet": "権限セット", "settings.permissionSetDescription": "アプリとナレッジベースで使用する権限セットを設定します。権限セットは、特定のリソースに対してメンバーに割り当てられる、リソース操作権限の再利用可能なコレクションです。", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index f732e86b7af301..8cc41adcaebf0c 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "통합", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "멤버", "settings.permissionSet": "권한 세트", "settings.permissionSetDescription": "앱과 지식 베이스에서 사용할 권한 세트를 구성합니다. 권한 세트는 특정 리소스에 대해 멤버에게 할당할 수 있는 재사용 가능한 리소스 작업 권한 모음입니다.", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index cb29d3a1f03aaa..be1cafc61f8850 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -550,6 +550,26 @@ "settings.extension": "ສ່ວນຂະຫຍາຍ", "settings.integrations": "ການເຊື່ອມຕໍ່", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "ສະມາຊິກ", "settings.permissionSet": "ຊຸດການອະນຸຍາດ", "settings.permissionSetDescription": "ຕັ້ງຄ່າຊຸດການອະນຸຍາດສຳລັບໃຊ້ກັບແອັບ ແລະ ຖານຄວາມຮູ້. ຊຸດການອະນຸຍາດແມ່ນການລວມເອົາສິດການດຳເນີນການຊັບພະຍາກອນທີ່ສາມາດນຳມາໃຊ້ຄືນໄດ້ ເຊິ່ງສາມາດມອບໝາຍໃຫ້ສະມາຊິກສຳລັບຊັບພະຍາກອນສະເພາະ.", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 168e7b91ac2b46..c2dee83d9fb289 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrations", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Members", "settings.permissionSet": "Set met rechten", "settings.permissionSetDescription": "Configureer sets met rechten voor gebruik met apps en kennisbanken. Een set met rechten is een herbruikbare verzameling rechten voor resourcebewerkingen die aan leden kan worden toegewezen voor specifieke resources.", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index e89d9ea79ea4f4..4ac76a130335e3 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integracje", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Członkowie", "settings.permissionSet": "Zestaw uprawnień", "settings.permissionSetDescription": "Skonfiguruj zestawy uprawnień do użycia z aplikacjami i bazami wiedzy. Zestaw uprawnień to wielokrotnego użytku zbiór uprawnień do operacji na zasobach, który można przypisywać członkom dla konkretnych zasobów.", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index b73bbbc18f51de..17d2e4b1ecf8b9 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrações", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Membros", "settings.permissionSet": "Conjunto de permissões", "settings.permissionSetDescription": "Configure conjuntos de permissões para uso com apps e bases de conhecimento. Um conjunto de permissões é uma coleção reutilizável de permissões de operações de recursos que pode ser atribuída a membros para recursos específicos.", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 9dfb86ca7fe519..2180c8f8521f52 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integrări", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Membri", "settings.permissionSet": "Set de permisiuni", "settings.permissionSetDescription": "Configurați seturi de permisiuni pentru utilizare cu aplicații și baze de cunoștințe. Un set de permisiuni este o colecție reutilizabilă de permisiuni pentru operațiuni asupra resurselor, care poate fi atribuită membrilor pentru resurse specifice.", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 9403297e621dfa..0a96812530d031 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Интеграции", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Участники", "settings.permissionSet": "Набор разрешений", "settings.permissionSetDescription": "Настройте наборы разрешений для использования с приложениями и базами знаний. Набор разрешений — это многократно используемая коллекция разрешений на операции с ресурсами, которую можно назначать участникам для конкретных ресурсов.", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 1ab88849a8fd34..02813334ac5c63 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Integracije", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Člani", "settings.permissionSet": "Nabor dovoljenj", "settings.permissionSetDescription": "Konfigurirajte nabore dovoljenj za uporabo z aplikacijami in bazami znanja. Nabor dovoljenj je ponovno uporabna zbirka dovoljenj za operacije nad viri, ki jo je mogoče dodeliti članom za določene vire.", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 1174e5a458873f..0f6bb0adbc766a 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "บูรณาการ", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "สมาชิก", "settings.permissionSet": "ชุดสิทธิ์", "settings.permissionSetDescription": "กำหนดค่าชุดสิทธิ์สำหรับใช้กับแอปและฐานความรู้ ชุดสิทธิ์คือชุดสิทธิ์การดำเนินการกับทรัพยากรที่นำกลับมาใช้ซ้ำได้ และสามารถกำหนดให้สมาชิกสำหรับทรัพยากรเฉพาะได้", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 07a1fd64e0bab1..7956af6c956cc2 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Entegrasyonlar", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Üyeler", "settings.permissionSet": "İzin Seti", "settings.permissionSetDescription": "Uygulamalar ve bilgi tabanlarıyla kullanılacak izin setlerini yapılandırın. İzin seti, belirli kaynaklar için üyelere atanabilen yeniden kullanılabilir kaynak işlem izinleri koleksiyonudur.", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 7f64da9664ea0b..a04efebf8ff416 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Інтеграції", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Учасники", "settings.permissionSet": "Набір дозволів", "settings.permissionSetDescription": "Налаштуйте набори дозволів для використання із застосунками та базами знань. Набір дозволів — це багаторазова колекція дозволів на операції з ресурсами, яку можна призначати учасникам для конкретних ресурсів.", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 7a1f224f56eb00..e2840a3a4338a7 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -550,6 +550,26 @@ "settings.extension": "Extension", "settings.integrations": "Tích hợp", "settings.ipPolicies": "IP Policies", + "settings.ipPoliciesDescription": "Reusable rules that control which IP addresses or ranges can access your apps", + "settings.ipPolicyAddCurrentIp": "Add it", + "settings.ipPolicyAddEntry": "Add", + "settings.ipPolicyAllowlist": "Allowlist", + "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCreate": "Create", + "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", + "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", + "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", + "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", + "settings.ipPolicyEntryOctetRange": "Each octet must be 0–255", + "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", + "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", + "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyName": "Name", + "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", + "settings.ipPolicyNewTitle": "New IP Policy", + "settings.ipPolicyRemoveEntry": "Remove entry", "settings.members": "Thành viên", "settings.permissionSet": "Bộ quyền", "settings.permissionSetDescription": "Định cấu hình các bộ quyền để sử dụng với ứng dụng và cơ sở kiến thức. Bộ quyền là tập hợp có thể tái sử dụng các quyền thao tác trên tài nguyên, có thể được gán cho thành viên đối với các tài nguyên cụ thể.", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index c0994496b573b6..3459b396f6e6bb 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -550,6 +550,26 @@ "settings.extension": "扩展", "settings.integrations": "集成", "settings.ipPolicies": "IP 策略", + "settings.ipPoliciesDescription": "可复用的规则,用于控制允许访问的 IP 地址或网段", + "settings.ipPolicyAddCurrentIp": "添加", + "settings.ipPolicyAddEntry": "添加", + "settings.ipPolicyAllowlist": "允许列表", + "settings.ipPolicyAllowlistHelp": "支持单个地址(203.0.113.42)或 CIDR 网段(10.0.0.0/8),IPv4 与 IPv6 均可。", + "settings.ipPolicyCreate": "创建", + "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", + "settings.ipPolicyDialogDescription": "指定允许访问的 IP 地址或网段。", + "settings.ipPolicyEditTitle": "编辑 IP 策略", + "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 地址", + "settings.ipPolicyEntryLeadingZeros": "不允许前导零", + "settings.ipPolicyEntryMultipleSlashes": "只能包含一个“/”", + "settings.ipPolicyEntryOctetRange": "每段数值必须在 0–255 之间", + "settings.ipPolicyEntryPrefixNotNumber": "前缀长度必须为数字", + "settings.ipPolicyEntryPrefixRange": "前缀长度必须在 0–{{max}} 之间", + "settings.ipPolicyEntryUnsupported": "仅支持 IPv4 / IPv6 地址和 CIDR 网段", + "settings.ipPolicyName": "名称", + "settings.ipPolicyNamePlaceholder": "例:内部网络", + "settings.ipPolicyNewTitle": "新建 IP 策略", + "settings.ipPolicyRemoveEntry": "删除条目", "settings.members": "成员", "settings.permissionSet": "权限集", "settings.permissionSetDescription": "配置可供应用和知识库使用的权限集。权限集是一组可复用的资源操作权限,可针对特定资源分配给成员。", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 04beac4950952f..4db680682bf995 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -550,6 +550,26 @@ "settings.extension": "擴充", "settings.integrations": "集成", "settings.ipPolicies": "IP 策略", + "settings.ipPoliciesDescription": "可重用的規則,用來控制允許存取的 IP 位址或網段", + "settings.ipPolicyAddCurrentIp": "加入", + "settings.ipPolicyAddEntry": "新增", + "settings.ipPolicyAllowlist": "允許清單", + "settings.ipPolicyAllowlistHelp": "支援單一地址(203.0.113.42)或 CIDR 網段(10.0.0.0/8),IPv4 與 IPv6 皆可。", + "settings.ipPolicyCreate": "建立", + "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", + "settings.ipPolicyDialogDescription": "指定允許存取的 IP 位址或網段。", + "settings.ipPolicyEditTitle": "編輯 IP 策略", + "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 位址", + "settings.ipPolicyEntryLeadingZeros": "不允許前導零", + "settings.ipPolicyEntryMultipleSlashes": "只能包含一個“/”", + "settings.ipPolicyEntryOctetRange": "每段數值必須在 0–255 之間", + "settings.ipPolicyEntryPrefixNotNumber": "前置碼長度必須為數字", + "settings.ipPolicyEntryPrefixRange": "前置碼長度必須在 0–{{max}} 之間", + "settings.ipPolicyEntryUnsupported": "僅支援 IPv4 / IPv6 位址和 CIDR 網段", + "settings.ipPolicyName": "名稱", + "settings.ipPolicyNamePlaceholder": "例:內部網路", + "settings.ipPolicyNewTitle": "新增 IP 策略", + "settings.ipPolicyRemoveEntry": "刪除條目", "settings.members": "成員", "settings.permissionSet": "權限集", "settings.permissionSetDescription": "配置可供應用和知識庫使用的權限集。權限集是一組可重複使用的資源操作權限,可針對特定資源分配給成員。", From 72af960c3039ebb657ff3566ccd55205b12326f8 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 9 Sep 2026 13:55:43 +0800 Subject: [PATCH 012/108] fix(web): keep IP Policy dialog corners round when allowlist scrolls Clip overflow on the dialog shell and scroll only the allowlist rows so the scrollbar no longer squares the rounded corners. --- .../ip-policies-page/allowlist-field.tsx | 10 +++++----- .../ip-policies-page/policy-dialog.tsx | 13 ++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx index 374a734812bfe6..e712ba6648a583 100644 --- a/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx @@ -64,15 +64,15 @@ export function AllowlistField({ entries, currentIp, onEntriesChange }: Allowlis } return ( - -
+ +
{t(($) => $['settings.ipPolicyAllowlist'], { ns: 'common' })} {t(($) => $['settings.ipPolicyAllowlistHelp'], { ns: 'common' })}
-
+
{entries.map((entry) => { const result = validateIpEntry(entry.value) const invalid = result.kind === 'invalid' @@ -118,7 +118,7 @@ export function AllowlistField({ entries, currentIp, onEntriesChange }: Allowlis type="button" variant="secondary" size="small" - className="self-start" + className="shrink-0 self-start" onClick={() => onEntriesChange([...entries, createAllowlistRow()])} > @@ -126,7 +126,7 @@ export function AllowlistField({ entries, currentIp, onEntriesChange }: Allowlis {currentIp && ( -

+

{t(($) => $['settings.ipPolicyCurrentIp'], { ns: 'common', ip: currentIp })} From 7e3ceaba82c99f32862dad39c0e52e9962ad7215 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 15:41:26 +0800 Subject: [PATCH 013/108] feat: align network access BFF with app policies --- .../console/workspace/network_access_group.py | 136 ++++++++- api/controllers/console/wraps.py | 21 +- api/openapi/markdown/console-openapi.md | 24 +- api/services/billing_service.py | 12 +- api/services/network_access_group_service.py | 74 +++++ .../workspace/test_network_access_group.py | 286 ++++++++++++++++-- ...st_network_access_group_billing_service.py | 52 +++- .../generated/api/console/apps/types.gen.ts | 6 + .../generated/api/console/apps/zod.gen.ts | 6 + .../api/console/workspaces/types.gen.ts | 16 +- .../api/console/workspaces/zod.gen.ts | 83 ++--- 11 files changed, 605 insertions(+), 111 deletions(-) create mode 100644 api/services/network_access_group_service.py diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index 39860e8cfa7524..eb294fc74c17ff 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -1,9 +1,9 @@ from datetime import datetime -from typing import Literal +from typing import Literal, Self from uuid import UUID from flask_restx import Resource -from pydantic import AliasChoices, BaseModel, Field, ValidationError +from pydantic import AliasChoices, BaseModel, Field, ValidationError, model_validator from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models @@ -11,6 +11,8 @@ from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, + cloud_edition_billing_paid_plan_required, + is_cloud_edition_billing_paid_plan, model_validate, only_edition_cloud, setup_required, @@ -20,17 +22,25 @@ from fields.base import ResponseModel from libs.helper import dump_response from libs.login import login_required -from models import Account, App, TenantAccountRole +from models import Account, App, AppMode, TenantAccountRole from services.billing_service import BillingService, NetworkAccessGroupUpstreamError +from services.network_access_group_service import NetworkAccessGroupService -NetworkAccessGroupMode = Literal["disabled", "shadow", "enforce"] +NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"] + +_ACCESS_POINTS_BY_APP_MODE: dict[AppMode, tuple[NetworkAccessPoint, ...]] = { + AppMode.WORKFLOW: ("webapp", "service_api", "mcp", "trigger"), + AppMode.ADVANCED_CHAT: ("webapp", "service_api", "mcp"), + AppMode.CHAT: ("webapp", "service_api", "mcp"), + AppMode.COMPLETION: ("webapp", "service_api", "mcp"), + AppMode.AGENT_CHAT: ("webapp", "service_api"), +} class NetworkAccessGroupCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=100) description: str = Field(default="", max_length=500) - mode: NetworkAccessGroupMode - allowed_cidrs: list[str] = Field(max_length=100) + allowed_cidrs: list[str] = Field(min_length=1, max_length=100) class NetworkAccessGroupUpdatePayload(NetworkAccessGroupCreatePayload): @@ -42,21 +52,51 @@ class NetworkAccessGroupDeleteQuery(BaseModel): class AppNetworkAccessGroupUpdatePayload(BaseModel): - group_id: UUID | None + enabled: bool + group_id: UUID | None = Field( + validation_alias=AliasChoices("group_id", "groupId", "policy_id", "policyId"), + ) + access_points: list[NetworkAccessPoint] = Field( + max_length=4, + validation_alias=AliasChoices("access_points", "accessPoints"), + ) expected_version: int = Field(ge=0) + @model_validator(mode="after") + def validate_enabled_configuration(self) -> Self: + if len(set(self.access_points)) != len(self.access_points): + raise ValueError("access_points must not contain duplicates") + if self.enabled and self.group_id is None: + raise ValueError("group_id is required when network access control is enabled") + if self.enabled and not self.access_points: + raise ValueError("access_points must not be empty when network access control is enabled") + return self + + +class NetworkAccessGroupAppResponse(ResponseModel): + id: str + name: str + icon: str | None = None + icon_type: str | None = None + icon_background: str | None = None + class NetworkAccessGroupResponse(ResponseModel): id: str tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) name: str description: str = "" - mode: NetworkAccessGroupMode allowed_cidrs: list[str] = Field( - default_factory=list, + min_length=1, + max_length=100, validation_alias=AliasChoices("allowed_cidrs", "allowedCidrs"), ) version: int = Field(ge=1) + used_by_count: int = Field(ge=0, validation_alias=AliasChoices("used_by_count", "usedByCount")) + app_ids: list[str] = Field( + validation_alias=AliasChoices("app_ids", "appIds", "used_by_app_ids", "usedByAppIds"), + ) + apps: list[NetworkAccessGroupAppResponse] updated_by_account_id: str | None = Field( default=None, validation_alias=AliasChoices("updated_by_account_id", "updatedByAccountId"), @@ -83,7 +123,15 @@ class AppNetworkAccessGroupBindingResponse(ResponseModel): id: str tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) app_id: str = Field(validation_alias=AliasChoices("app_id", "appId")) - group_id: str | None = Field(default=None, validation_alias=AliasChoices("group_id", "groupId")) + enabled: bool + group_id: str | None = Field( + default=None, + validation_alias=AliasChoices("group_id", "groupId", "policy_id", "policyId"), + ) + access_points: list[NetworkAccessPoint] = Field( + max_length=4, + validation_alias=AliasChoices("access_points", "accessPoints"), + ) version: int = Field(ge=1) updated_by_account_id: str | None = Field( default=None, @@ -92,16 +140,28 @@ class AppNetworkAccessGroupBindingResponse(ResponseModel): created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt")) updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt")) + @model_validator(mode="after") + def validate_enabled_configuration(self) -> Self: + if len(set(self.access_points)) != len(self.access_points): + raise ValueError("access_points must not contain duplicates") + if self.enabled and self.group_id is None: + raise ValueError("group_id is required when network access control is enabled") + if self.enabled and not self.access_points: + raise ValueError("access_points must not be empty when network access control is enabled") + return self + class AppNetworkAccessGroupResponse(ResponseModel): tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) app_id: str = Field(validation_alias=AliasChoices("app_id", "appId")) entitled: bool + available_access_points: list[NetworkAccessPoint] binding: AppNetworkAccessGroupBindingResponse | None class AppNetworkAccessGroupMutationResponse(ResponseModel): binding: AppNetworkAccessGroupBindingResponse + available_access_points: list[NetworkAccessPoint] register_schema_models( @@ -112,6 +172,7 @@ class AppNetworkAccessGroupMutationResponse(ResponseModel): ) register_response_schema_models( console_ns, + NetworkAccessGroupAppResponse, NetworkAccessGroupResponse, NetworkAccessGroupListResponse, NetworkAccessGroupMutationResponse, @@ -122,6 +183,39 @@ class AppNetworkAccessGroupMutationResponse(ResponseModel): ) +def _available_access_points(app_model: App) -> list[NetworkAccessPoint]: + app_mode = app_model.mode if isinstance(app_model.mode, AppMode) else AppMode.value_of(str(app_model.mode)) + available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode) + if available is None: + raise BadRequest(f"Network access control is not supported for app mode '{app_mode.value}'.") + return list(available) + + +def _validate_app_access_points( + requested: list[NetworkAccessPoint], + available: list[NetworkAccessPoint], +) -> None: + unsupported = sorted(set(requested).difference(available)) + if unsupported: + raise BadRequest(f"Unsupported access points for this app: {', '.join(unsupported)}.") + + +def _effective_entitlement(tenant_id: str, upstream_entitled: object) -> bool: + return bool(upstream_entitled) and is_cloud_edition_billing_paid_plan(tenant_id) + + +def _normalize_app_binding_defaults(payload: dict) -> None: + """Materialize ProtoJSON defaults so the Console response stays stable.""" + + payload.setdefault("binding", None) + binding = payload.get("binding") + if not isinstance(binding, dict): + return + binding.setdefault("enabled", False) + if "access_points" not in binding and "accessPoints" not in binding: + binding["access_points"] = [] + + def _ensure_workspace_admin_or_owner(current_user: Account) -> None: """Authorize management against the persisted current-workspace membership.""" @@ -145,7 +239,6 @@ def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception "NETWORK_ACCESS_VERSION_CONFLICT": "The network access resource changed. Refresh it and try again.", "NETWORK_ACCESS_GROUP_NAME_CONFLICT": "A network access group with this name already exists.", "NETWORK_ACCESS_GROUP_LIMIT": "This workspace has reached the network access group limit.", - "NETWORK_ACCESS_GROUP_BOUND": ("Unassign this network access group from every app before deleting it."), } return Conflict(message_by_reason.get(exc.reason or "", "The network access resource is in conflict.")) if exc.status_code >= 500: @@ -179,6 +272,8 @@ def get(self, current_tenant_id: str, current_user: Account): payload = BillingService.list_network_access_groups(current_tenant_id, current_user.id) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled")) + NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) return _serialize_response(NetworkAccessGroupListResponse, payload) @console_ns.expect(console_ns.models[NetworkAccessGroupCreatePayload.__name__]) @@ -191,6 +286,7 @@ def get(self, current_tenant_id: str, current_user: Account): @login_required @account_initialization_required @only_edition_cloud + @cloud_edition_billing_paid_plan_required @with_current_user @with_current_tenant_id @model_validate(NetworkAccessGroupCreatePayload) @@ -206,12 +302,12 @@ def post( current_tenant_id, name=req_data.name, description=req_data.description, - mode=req_data.mode, allowed_cidrs=req_data.allowed_cidrs, actor_account_id=current_user.id, ) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) return _serialize_response(NetworkAccessGroupMutationResponse, payload), 201 @@ -234,6 +330,7 @@ def get(self, current_tenant_id: str, current_user: Account, group_id: UUID): payload = BillingService.get_network_access_group(current_tenant_id, str(group_id), current_user.id) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) return _serialize_response(NetworkAccessGroupMutationResponse, payload) @console_ns.expect(console_ns.models[NetworkAccessGroupUpdatePayload.__name__]) @@ -246,6 +343,7 @@ def get(self, current_tenant_id: str, current_user: Account, group_id: UUID): @login_required @account_initialization_required @only_edition_cloud + @cloud_edition_billing_paid_plan_required @with_current_user @with_current_tenant_id @model_validate(NetworkAccessGroupUpdatePayload) @@ -263,13 +361,13 @@ def put( str(group_id), name=req_data.name, description=req_data.description, - mode=req_data.mode, allowed_cidrs=req_data.allowed_cidrs, expected_version=req_data.expected_version, actor_account_id=current_user.id, ) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) return _serialize_response(NetworkAccessGroupMutationResponse, payload) @console_ns.doc(params=query_params_from_model(NetworkAccessGroupDeleteQuery)) @@ -282,6 +380,7 @@ def put( @login_required @account_initialization_required @only_edition_cloud + @cloud_edition_billing_paid_plan_required @with_current_user @with_current_tenant_id @model_validate(NetworkAccessGroupDeleteQuery) @@ -321,6 +420,7 @@ class AppNetworkAccessGroupApi(Resource): @get_app_model def get(self, current_tenant_id: str, current_user: Account, app_model: App): _ensure_workspace_admin_or_owner(current_user) + available_access_points = _available_access_points(app_model) try: payload = BillingService.get_app_network_access_group( current_tenant_id, @@ -329,6 +429,9 @@ def get(self, current_tenant_id: str, current_user: Account, app_model: App): ) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + _normalize_app_binding_defaults(payload) + payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled")) + payload["available_access_points"] = available_access_points return _serialize_response(AppNetworkAccessGroupResponse, payload) @console_ns.expect(console_ns.models[AppNetworkAccessGroupUpdatePayload.__name__]) @@ -341,6 +444,7 @@ def get(self, current_tenant_id: str, current_user: Account, app_model: App): @login_required @account_initialization_required @only_edition_cloud + @cloud_edition_billing_paid_plan_required @with_current_user @with_current_tenant_id @get_app_model @@ -353,14 +457,20 @@ def put( app_model: App, ): _ensure_workspace_admin_or_owner(current_user) + available_access_points = _available_access_points(app_model) + _validate_app_access_points(req_data.access_points, available_access_points) try: payload = BillingService.update_app_network_access_group( current_tenant_id, str(app_model.id), + enabled=req_data.enabled, group_id=str(req_data.group_id) if req_data.group_id is not None else None, + access_points=list(req_data.access_points), expected_version=req_data.expected_version, actor_account_id=current_user.id, ) except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc + _normalize_app_binding_defaults(payload) + payload["available_access_points"] = available_access_points return _serialize_response(AppNetworkAccessGroupMutationResponse, payload) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 8e353c6c980664..f279a70f2ed362 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -159,15 +159,26 @@ def decorated(*args: P.args, **kwargs: P.kwargs): return decorated +def is_cloud_edition_billing_paid_plan(tenant_id: str) -> bool: + """Return whether a Cloud workspace has a paid subscription. + + Keep this predicate shared with the mutation decorator so read endpoints can + expose an entitlement flag without rejecting Sandbox workspaces that need to + render an upgrade state. + """ + + billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + return billing_info["enabled"] and billing_info["subscription"]["plan"] in ( + CloudPlan.PROFESSIONAL, + CloudPlan.TEAM, + ) + + def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() - billing_info = BillingService.get_info(current_tenant_id, exclude_vector_space=True) - if not billing_info["enabled"] or billing_info["subscription"]["plan"] not in ( - CloudPlan.PROFESSIONAL, - CloudPlan.TEAM, - ): + if not is_cloud_edition_billing_paid_plan(current_tenant_id): abort(403, "This feature requires a paid plan.") return view(*args, **kwargs) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ee8dedb1ebe1e1..ed560ec41f4214 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -15329,8 +15329,10 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | | app_id | string | | Yes | | created_at | dateTime | | Yes | +| enabled | boolean | | Yes | | group_id | string | | No | | id | string | | Yes | | tenant_id | string | | Yes | @@ -15342,6 +15344,7 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | | binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | #### AppNetworkAccessGroupResponse @@ -15349,6 +15352,7 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | app_id | string | | Yes | +| available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | | binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | | entitled | boolean | | Yes | | tenant_id | string | | Yes | @@ -15357,6 +15361,8 @@ AppMCPServer Status Enum | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | +| enabled | boolean | | Yes | | expected_version | integer | | Yes | | group_id | string | | Yes | @@ -19254,13 +19260,22 @@ Model with provider entity. | dataset | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | | workspace | [WorkspacePermissionSnapshot](#workspacepermissionsnapshot) | | No | +#### NetworkAccessGroupAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| name | string | | Yes | + #### NetworkAccessGroupCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | allowed_cidrs | [ string ] | | Yes | | description | string | | No | -| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | | name | string | | Yes | #### NetworkAccessGroupDeleteResponse @@ -19287,15 +19302,17 @@ Model with provider entity. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| allowed_cidrs | [ string ] | | No | +| allowed_cidrs | [ string ] | | Yes | +| app_ids | [ string ] | | Yes | +| apps | [ [NetworkAccessGroupAppResponse](#networkaccessgroupappresponse) ] | | Yes | | created_at | dateTime | | Yes | | description | string | | No | | id | string | | Yes | -| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | | name | string | | Yes | | tenant_id | string | | Yes | | updated_at | dateTime | | Yes | | updated_by_account_id | string | | No | +| used_by_count | integer | | Yes | | version | integer | | Yes | #### NetworkAccessGroupUpdatePayload @@ -19305,7 +19322,6 @@ Model with provider entity. | allowed_cidrs | [ string ] | | Yes | | description | string | | No | | expected_version | integer | | Yes | -| mode | string,
**Available values:** "disabled", "enforce", "shadow" | *Enum:* `"disabled"`, `"enforce"`, `"shadow"` | Yes | | name | string | | Yes | #### NewAppResponse diff --git a/api/services/billing_service.py b/api/services/billing_service.py index c5b7314f4f2b16..43a58acc525c65 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -450,7 +450,6 @@ def create_network_access_group( *, name: str, description: str, - mode: str, allowed_cidrs: list[str], actor_account_id: str, ) -> dict[str, Any]: @@ -462,7 +461,6 @@ def create_network_access_group( payload_json={ "name": name, "description": description, - "mode": mode, "allowed_cidrs": allowed_cidrs, "actor_account_id": actor_account_id, }, @@ -491,7 +489,6 @@ def update_network_access_group( *, name: str, description: str, - mode: str, allowed_cidrs: list[str], expected_version: int, actor_account_id: str, @@ -504,7 +501,6 @@ def update_network_access_group( payload_json={ "name": name, "description": description, - "mode": mode, "allowed_cidrs": allowed_cidrs, "expected_version": expected_version, "actor_account_id": actor_account_id, @@ -520,7 +516,7 @@ def delete_network_access_group( expected_version: int, actor_account_id: str, ) -> dict[str, Any]: - """Delete an unbound reusable group using optimistic concurrency control.""" + """Delete a reusable group using optimistic concurrency control.""" return cls._send_network_access_group_request( "DELETE", @@ -552,17 +548,21 @@ def update_app_network_access_group( tenant_id: str, app_id: str, *, + enabled: bool, group_id: str | None, + access_points: list[str], expected_version: int, actor_account_id: str, ) -> dict[str, Any]: - """Assign or unassign one reusable group from a tenant-scoped app.""" + """Atomically replace one tenant-scoped App's complete network access configuration.""" return cls._send_network_access_group_request( "PUT", f"/tenants/{tenant_id}/apps/{app_id}/network-access-group", payload_json={ + "enabled": enabled, "group_id": group_id, + "access_points": access_points, "expected_version": expected_version, "actor_account_id": actor_account_id, }, diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py new file mode 100644 index 00000000000000..02c3685a37a118 --- /dev/null +++ b/api/services/network_access_group_service.py @@ -0,0 +1,74 @@ +from typing import Any + +from sqlalchemy import select + +from extensions.ext_database import db +from models import App + + +class NetworkAccessGroupService: + """Dify-side orchestration for SaaS network access group responses.""" + + @staticmethod + def enrich_app_references( + payload: dict[str, Any], + tenant_id: str, + ) -> dict[str, Any]: + """Attach tenant-scoped App display metadata to one or more group payloads.""" + + raw_groups: list[object] = [] + if isinstance(payload.get("group"), dict): + raw_groups.append(payload["group"]) + if isinstance(payload.get("groups"), list): + raw_groups.extend(payload["groups"]) + + groups = [group for group in raw_groups if isinstance(group, dict)] + for group in groups: + raw_app_ids = ( + group.get("used_by_app_ids") + or group.get("usedByAppIds") + or group.get("app_ids") + or group.get("appIds") + or [] + ) + group["app_ids"] = list(raw_app_ids) if isinstance(raw_app_ids, list) else raw_app_ids + raw_count = group.get("used_by_count") + if raw_count is None: + raw_count = group.get("usedByCount") + if raw_count is None: + raw_count = len(raw_app_ids) if isinstance(raw_app_ids, list) else 0 + group["used_by_count"] = raw_count + + app_ids = { + str(app_id) + for group in groups + if isinstance(group["app_ids"], list) + for app_id in group["app_ids"] + if app_id + } + if not app_ids: + for group in groups: + group["apps"] = [] + return payload + + app_models = db.session.scalars( + select(App).where( + App.tenant_id == tenant_id, + App.id.in_(app_ids), + App.status == "normal", + ) + ).all() + apps_by_id = { + str(app.id): { + "id": str(app.id), + "name": app.name, + "icon": app.icon, + "icon_type": getattr(app.icon_type, "value", app.icon_type) if app.icon_type is not None else None, + "icon_background": app.icon_background, + } + for app in app_models + } + for group in groups: + group_app_ids = group["app_ids"] if isinstance(group["app_ids"], list) else [] + group["apps"] = [apps_by_id[str(app_id)] for app_id in group_app_ids if str(app_id) in apps_by_id] + return payload diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 586a9e0f73671b..42e09bbe5036af 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -1,7 +1,9 @@ +from collections.abc import Iterator from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace -from unittest.mock import patch +from typing import cast +from unittest.mock import MagicMock, patch from uuid import UUID import pytest @@ -16,10 +18,13 @@ NetworkAccessGroupCreatePayload, NetworkAccessGroupDeleteQuery, NetworkAccessGroupUpdatePayload, + _available_access_points, + _effective_entitlement, _translate_upstream_error, ) -from models import TenantAccountRole +from models import App, AppMode, TenantAccountRole from services.billing_service import BillingService, NetworkAccessGroupUpstreamError +from services.network_access_group_service import NetworkAccessGroupService TENANT_ID = "11111111-1111-4111-8111-111111111111" ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" @@ -32,14 +37,25 @@ def _current_user(role: TenantAccountRole = TenantAccountRole.OWNER) -> SimpleNa return SimpleNamespace(id=ACCOUNT_ID, current_role=role) -def _group_payload() -> dict[str, object]: +@pytest.fixture(autouse=True) +def paid_entitlement() -> Iterator[None]: + with patch( + "controllers.console.workspace.network_access_group._effective_entitlement", + side_effect=lambda _tenant_id, upstream: bool(upstream), + ): + yield + + +def _group_payload(*, app_ids: list[str] | None = None) -> dict[str, object]: + app_ids = app_ids or [] return { "id": GROUP_ID, "tenantId": TENANT_ID, "name": "Office network", "description": "Reusable office egress addresses", - "mode": "enforce", "allowedCidrs": ["203.0.113.7/32"], + "usedByCount": len(app_ids), + "usedByAppIds": app_ids, # ProtoJSON represents int64 values as strings and timestamps as RFC 3339. "version": "2", "updatedByAccountId": ACCOUNT_ID, @@ -48,12 +64,19 @@ def _group_payload() -> dict[str, object]: } -def _binding_payload(*, group_id: str | None = GROUP_ID) -> dict[str, object]: +def _binding_payload( + *, + enabled: bool = True, + group_id: str | None = GROUP_ID, + access_points: list[str] | None = None, +) -> dict[str, object]: return { "id": BINDING_ID, "tenantId": TENANT_ID, "appId": APP_ID, + "enabled": enabled, "groupId": group_id, + "accessPoints": ["webapp", "service_api"] if access_points is None else access_points, "version": "3", "updatedByAccountId": ACCOUNT_ID, "createdAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), @@ -76,6 +99,9 @@ def test_list_authorizes_and_forwards_tenant_and_actor() -> None: assert result["tenant_id"] == TENANT_ID assert result["groups"][0]["name"] == "Office network" assert result["groups"][0]["version"] == 2 + assert result["groups"][0]["used_by_count"] == 0 + assert result["groups"][0]["app_ids"] == [] + assert result["groups"][0]["apps"] == [] assert result["groups"][0]["updated_at"] == "2026-08-21T00:00:00Z" @@ -86,7 +112,6 @@ def test_create_injects_actor_and_returns_created_contract() -> None: request_payload = NetworkAccessGroupCreatePayload( name="Office network", description="Reusable office egress addresses", - mode="enforce", allowed_cidrs=["203.0.113.7/32"], ) @@ -108,7 +133,6 @@ def test_create_injects_actor_and_returns_created_contract() -> None: TENANT_ID, name="Office network", description="Reusable office egress addresses", - mode="enforce", allowed_cidrs=["203.0.113.7/32"], actor_account_id=ACCOUNT_ID, ) @@ -123,7 +147,6 @@ def test_update_group_forwards_path_id_and_expected_version() -> None: request_payload = NetworkAccessGroupUpdatePayload( name="Office network", description="Updated", - mode="shadow", allowed_cidrs=["203.0.113.0/24"], expected_version=1, ) @@ -148,7 +171,6 @@ def test_update_group_forwards_path_id_and_expected_version() -> None: GROUP_ID, name="Office network", description="Updated", - mode="shadow", allowed_cidrs=["203.0.113.0/24"], expected_version=1, actor_account_id=ACCOUNT_ID, @@ -189,8 +211,8 @@ def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> N api = AppNetworkAccessGroupApi() method = unwrap(api.get) current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) - upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True, "binding": None} + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) + upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True} with ( patch.object( @@ -208,21 +230,27 @@ def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> N get_binding.assert_called_once_with(TENANT_ID, APP_ID, ACCOUNT_ID) assert result["binding"] is None + assert result["available_access_points"] == ["webapp", "service_api", "mcp"] -@pytest.mark.parametrize("group_id", [GROUP_ID, None]) -def test_app_put_assigns_or_unassigns_group(group_id: str | None) -> None: +@pytest.mark.parametrize("enabled", [True, False]) +def test_app_put_atomically_saves_enabled_group_and_access_points(enabled: bool) -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.put) current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) - request_payload = AppNetworkAccessGroupUpdatePayload(group_id=group_id, expected_version=2) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) + request_payload = AppNetworkAccessGroupUpdatePayload( + enabled=enabled, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=2, + ) with ( patch.object( BillingService, "update_app_network_access_group", - return_value={"binding": _binding_payload(group_id=group_id)}, + return_value={"binding": _binding_payload(enabled=enabled)}, ) as update_binding, ): result = method( @@ -236,12 +264,17 @@ def test_app_put_assigns_or_unassigns_group(group_id: str | None) -> None: update_binding.assert_called_once_with( TENANT_ID, APP_ID, - group_id=group_id, + enabled=enabled, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], expected_version=2, actor_account_id=ACCOUNT_ID, ) - assert result["binding"]["group_id"] == group_id + assert result["binding"]["enabled"] is enabled + assert result["binding"]["group_id"] == GROUP_ID + assert result["binding"]["access_points"] == ["webapp", "service_api"] assert result["binding"]["version"] == 3 + assert result["available_access_points"] == ["webapp", "service_api", "mcp"] def test_list_rejects_non_privileged_workspace_member_before_upstream_call() -> None: @@ -280,7 +313,6 @@ def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPE ("NETWORK_ACCESS_VERSION_CONFLICT", "changed"), ("NETWORK_ACCESS_GROUP_NAME_CONFLICT", "already exists"), ("NETWORK_ACCESS_GROUP_LIMIT", "reached"), - ("NETWORK_ACCESS_GROUP_BOUND", "Unassign"), ], ) def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, expected_message: str) -> None: @@ -304,20 +336,21 @@ def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None: patch.object( BillingService, "list_network_access_groups", - return_value={"tenant_id": TENANT_ID, "entitled": True, "groups": [{"mode": "disabled"}]}, + return_value={"tenant_id": TENANT_ID, "entitled": True, "groups": [{"allowed_cidrs": []}]}, ), pytest.raises(BadGateway), ): method(api, current_tenant_id=TENANT_ID, current_user=current_user) -def test_response_contract_accepts_protojson_omitted_empty_group_fields() -> None: +def test_response_contract_accepts_protojson_omitted_optional_group_fields() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) current_user = _current_user() group = _group_payload() group.pop("description") - group.pop("allowedCidrs") + group.pop("usedByAppIds") + group.pop("usedByCount") with ( patch.object( @@ -329,17 +362,26 @@ def test_response_contract_accepts_protojson_omitted_empty_group_fields() -> Non result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) assert result["groups"][0]["description"] == "" - assert result["groups"][0]["allowed_cidrs"] == [] + assert result["groups"][0]["allowed_cidrs"] == ["203.0.113.7/32"] + assert result["groups"][0]["app_ids"] == [] + assert result["groups"][0]["used_by_count"] == 0 -def test_binding_response_accepts_protojson_omitted_null_group_id() -> None: +def test_binding_response_materializes_protojson_omitted_disabled_defaults() -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.put) current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID) - request_payload = AppNetworkAccessGroupUpdatePayload(group_id=None, expected_version=2) - binding = _binding_payload(group_id=None) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) + request_payload = AppNetworkAccessGroupUpdatePayload( + enabled=False, + group_id=None, + access_points=[], + expected_version=2, + ) + binding = _binding_payload(enabled=False, group_id=None, access_points=[]) binding.pop("groupId") + binding.pop("enabled") + binding.pop("accessPoints") with ( patch.object( @@ -357,14 +399,41 @@ def test_binding_response_accepts_protojson_omitted_null_group_id() -> None: ) assert result["binding"]["group_id"] is None + assert result["binding"]["enabled"] is False + assert result["binding"]["access_points"] == [] + + +def test_binding_response_accepts_policy_id_alias() -> None: + api = AppNetworkAccessGroupApi() + method = unwrap(api.put) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) + request_payload = AppNetworkAccessGroupUpdatePayload( + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=2, + ) + binding = _binding_payload(access_points=["webapp"]) + binding["policyId"] = binding.pop("groupId") + + with patch.object(BillingService, "update_app_network_access_group", return_value={"binding": binding}): + result = method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=_current_user(), + app_model=app_model, + ) + + assert result["binding"]["group_id"] == GROUP_ID @pytest.mark.parametrize( "payload", [ - {"name": "", "description": "", "mode": "disabled", "allowed_cidrs": []}, - {"name": "group", "description": "", "mode": "unknown", "allowed_cidrs": []}, - {"name": "group", "description": "", "mode": "disabled", "allowed_cidrs": ["127.0.0.1"] * 101}, + {"name": "", "description": "", "allowed_cidrs": []}, + {"name": "group", "description": "", "allowed_cidrs": []}, + {"name": "group", "description": "", "allowed_cidrs": ["127.0.0.1"] * 101}, ], ) def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, object]) -> None: @@ -374,4 +443,159 @@ def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, objec def test_binding_payload_rejects_invalid_group_id_and_version() -> None: with pytest.raises(ValidationError): - AppNetworkAccessGroupUpdatePayload.model_validate({"group_id": "not-a-uuid", "expected_version": -1}) + AppNetworkAccessGroupUpdatePayload.model_validate( + { + "enabled": True, + "group_id": "not-a-uuid", + "access_points": ["webapp"], + "expected_version": -1, + } + ) + + +@pytest.mark.parametrize( + ("app_mode", "expected"), + [ + (AppMode.WORKFLOW, ["webapp", "service_api", "mcp", "trigger"]), + (AppMode.ADVANCED_CHAT, ["webapp", "service_api", "mcp"]), + (AppMode.CHAT, ["webapp", "service_api", "mcp"]), + (AppMode.COMPLETION, ["webapp", "service_api", "mcp"]), + (AppMode.AGENT_CHAT, ["webapp", "service_api"]), + ], +) +def test_available_access_points_are_derived_from_app_mode(app_mode: AppMode, expected: list[str]) -> None: + app_model = SimpleNamespace(mode=app_mode) + + assert _available_access_points(cast(App, app_model)) == expected + + +@pytest.mark.parametrize("app_mode", [AppMode.AGENT, AppMode.CHANNEL, AppMode.RAG_PIPELINE]) +def test_unsupported_app_modes_are_rejected(app_mode: AppMode) -> None: + with pytest.raises(BadRequest, match="not supported"): + _available_access_points(cast(App, SimpleNamespace(mode=app_mode))) + + +def test_app_put_rejects_access_point_not_available_for_mode_before_upstream_call() -> None: + api = AppNetworkAccessGroupApi() + method = unwrap(api.put) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.AGENT_CHAT) + request_payload = AppNetworkAccessGroupUpdatePayload( + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "mcp"], + expected_version=1, + ) + + with ( + patch.object(BillingService, "update_app_network_access_group") as update_binding, + pytest.raises(BadRequest, match="mcp"), + ): + method( + api, + req_data=request_payload, + current_tenant_id=TENANT_ID, + current_user=_current_user(), + app_model=app_model, + ) + + update_binding.assert_not_called() + + +@pytest.mark.parametrize( + "payload", + [ + {"enabled": True, "group_id": None, "access_points": ["webapp"], "expected_version": 0}, + {"enabled": True, "group_id": GROUP_ID, "access_points": [], "expected_version": 0}, + { + "enabled": True, + "group_id": GROUP_ID, + "access_points": ["webapp", "webapp"], + "expected_version": 0, + }, + {"enabled": True, "group_id": GROUP_ID, "access_points": ["unknown"], "expected_version": 0}, + ], +) +def test_enabled_app_config_rejects_incomplete_or_invalid_access_points(payload: dict[str, object]) -> None: + with pytest.raises(ValidationError): + AppNetworkAccessGroupUpdatePayload.model_validate(payload) + + +def test_app_config_accepts_policy_id_alias_and_disabled_draft() -> None: + payload = AppNetworkAccessGroupUpdatePayload.model_validate( + { + "enabled": False, + "policy_id": GROUP_ID, + "accessPoints": ["webapp", "service_api"], + "expected_version": 3, + } + ) + + assert str(payload.group_id) == GROUP_ID + assert payload.access_points == ["webapp", "service_api"] + + +def test_group_responses_are_enriched_with_tenant_scoped_app_metadata() -> None: + app_id_2 = "66666666-6666-4666-8666-666666666666" + payload = { + "group": _group_payload(app_ids=[APP_ID, app_id_2]), + } + payload["group"].pop("usedByCount") + app = SimpleNamespace( + id=APP_ID, + name="Customer support", + icon="robot", + icon_type=SimpleNamespace(value="emoji"), + icon_background="#FFFFFF", + ) + db_mock = MagicMock() + db_mock.session.scalars.return_value.all.return_value = [app] + + with patch("services.network_access_group_service.db", db_mock): + result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID) + + assert result["group"]["apps"] == [ + { + "id": APP_ID, + "name": "Customer support", + "icon": "robot", + "icon_type": "emoji", + "icon_background": "#FFFFFF", + } + ] + assert result["group"]["app_ids"] == [APP_ID, app_id_2] + assert result["group"]["used_by_count"] == 2 + db_mock.session.scalars.assert_called_once() + + +def test_sandbox_read_returns_not_entitled_for_upgrade_state() -> None: + api = CurrentWorkspaceNetworkAccessGroupsApi() + method = unwrap(api.get) + upstream_payload: dict[str, object] = { + "tenantId": TENANT_ID, + "entitled": True, + "groups": list[object](), + } + + with ( + patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload), + patch("controllers.console.workspace.network_access_group._effective_entitlement", return_value=False), + ): + result = method(api, current_tenant_id=TENANT_ID, current_user=_current_user()) + + assert result["entitled"] is False + + +@pytest.mark.parametrize( + ("upstream_entitled", "paid_plan", "expected"), + [(True, True, True), (False, True, False), (True, False, False)], +) +def test_effective_entitlement_requires_rollout_and_paid_plan( + upstream_entitled: bool, + paid_plan: bool, + expected: bool, +) -> None: + with patch( + "controllers.console.workspace.network_access_group.is_cloud_edition_billing_paid_plan", + return_value=paid_plan, + ): + assert _effective_entitlement(TENANT_ID, upstream_entitled) is expected diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py index 71c429ec2ed8ab..d24297efe09870 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -70,7 +70,6 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: TENANT_ID, name="Office", description="Office egress", - mode="enforce", allowed_cidrs=["203.0.113.7/32"], actor_account_id=ACCOUNT_ID, ) @@ -79,7 +78,6 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: GROUP_ID, name="Office", description="Updated office egress", - mode="shadow", allowed_cidrs=["203.0.113.0/24"], expected_version=1, actor_account_id=ACCOUNT_ID, @@ -98,7 +96,6 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: json={ "name": "Office", "description": "Office egress", - "mode": "enforce", "allowed_cidrs": ["203.0.113.7/32"], "actor_account_id": ACCOUNT_ID, }, @@ -112,7 +109,6 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: json={ "name": "Office", "description": "Updated office egress", - "mode": "shadow", "allowed_cidrs": ["203.0.113.0/24"], "expected_version": 1, "actor_account_id": ACCOUNT_ID, @@ -133,12 +129,28 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: @pytest.mark.usefixtures("billing_config") -def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: +def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None: response = MagicMock(status_code=httpx.codes.OK) response.json.side_effect = [ {"tenant_id": TENANT_ID, "app_id": APP_ID, "entitled": True, "binding": None}, - {"binding": {"app_id": APP_ID, "group_id": GROUP_ID, "version": "1"}}, - {"binding": {"app_id": APP_ID, "group_id": None, "version": "2"}}, + { + "binding": { + "app_id": APP_ID, + "enabled": True, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "version": "1", + } + }, + { + "binding": { + "app_id": APP_ID, + "enabled": False, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "version": "2", + } + }, ] with patch("services.billing_service._http_client.request", return_value=response) as request: @@ -146,14 +158,18 @@ def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: BillingService.update_app_network_access_group( TENANT_ID, APP_ID, + enabled=True, group_id=GROUP_ID, + access_points=["webapp", "service_api"], expected_version=0, actor_account_id=ACCOUNT_ID, ) BillingService.update_app_network_access_group( TENANT_ID, APP_ID, - group_id=None, + enabled=False, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], expected_version=1, actor_account_id=ACCOUNT_ID, ) @@ -171,7 +187,13 @@ def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: call( "PUT", endpoint, - json={"group_id": GROUP_ID, "expected_version": 0, "actor_account_id": ACCOUNT_ID}, + json={ + "enabled": True, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "expected_version": 0, + "actor_account_id": ACCOUNT_ID, + }, params=None, headers=HEADERS, follow_redirects=True, @@ -179,7 +201,13 @@ def test_app_binding_read_and_write_use_tenant_and_app_path() -> None: call( "PUT", endpoint, - json={"group_id": None, "expected_version": 1, "actor_account_id": ACCOUNT_ID}, + json={ + "enabled": False, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "expected_version": 1, + "actor_account_id": ACCOUNT_ID, + }, params=None, headers=HEADERS, follow_redirects=True, @@ -220,7 +248,7 @@ def test_network_access_group_request_preserves_upstream_status(status_code: int def test_network_access_group_request_preserves_whitelisted_upstream_reason() -> None: response = MagicMock(status_code=httpx.codes.CONFLICT) - response.json.return_value = {"code": 409, "reason": "NETWORK_ACCESS_GROUP_BOUND", "message": "internal"} + response.json.return_value = {"code": 409, "reason": "NETWORK_ACCESS_VERSION_CONFLICT", "message": "internal"} with ( patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, @@ -228,7 +256,7 @@ def test_network_access_group_request_preserves_whitelisted_upstream_reason() -> BillingService._send_network_access_group_request("DELETE", "/groups/id") assert exc_info.value.status_code == httpx.codes.CONFLICT - assert exc_info.value.reason == "NETWORK_ACCESS_GROUP_BOUND" + assert exc_info.value.reason == "NETWORK_ACCESS_VERSION_CONFLICT" def test_network_access_group_request_maps_transport_failure_to_service_unavailable() -> None: diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index b94f4f764163ac..95c089f6390b00 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -574,17 +574,21 @@ export type AppNamePayload = { export type AppNetworkAccessGroupResponse = { app_id: string + available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> binding: AppNetworkAccessGroupBindingResponse | null entitled: boolean tenant_id: string } export type AppNetworkAccessGroupUpdatePayload = { + access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> + enabled: boolean expected_version: number group_id: string | null } export type AppNetworkAccessGroupMutationResponse = { + available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> binding: AppNetworkAccessGroupBindingResponse } @@ -1648,8 +1652,10 @@ export type MessageFile = { } export type AppNetworkAccessGroupBindingResponse = { + access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> app_id: string created_at: string + enabled: boolean group_id?: string | null id: string tenant_id: string diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 299259af22cf71..9346323cad0d2e 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -350,6 +350,8 @@ export const zAppNamePayload = z.object({ * AppNetworkAccessGroupUpdatePayload */ export const zAppNetworkAccessGroupUpdatePayload = z.object({ + access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])).max(4), + enabled: z.boolean(), expected_version: z.int().gte(0), group_id: z.uuid().nullable(), }) @@ -1338,8 +1340,10 @@ export const zMessageFile = z.object({ * AppNetworkAccessGroupBindingResponse */ export const zAppNetworkAccessGroupBindingResponse = z.object({ + access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])).max(4), app_id: z.string(), created_at: z.iso.datetime(), + enabled: z.boolean(), group_id: z.string().nullish(), id: z.string(), tenant_id: z.string(), @@ -1353,6 +1357,7 @@ export const zAppNetworkAccessGroupBindingResponse = z.object({ */ export const zAppNetworkAccessGroupResponse = z.object({ app_id: z.string(), + available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), binding: zAppNetworkAccessGroupBindingResponse.nullable(), entitled: z.boolean(), tenant_id: z.string(), @@ -1362,6 +1367,7 @@ export const zAppNetworkAccessGroupResponse = z.object({ * AppNetworkAccessGroupMutationResponse */ export const zAppNetworkAccessGroupMutationResponse = z.object({ + available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), binding: zAppNetworkAccessGroupBindingResponse, }) diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 0922d0abc2d764..449a17de4b8ad0 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -373,7 +373,6 @@ export type NetworkAccessGroupListResponse = { export type NetworkAccessGroupCreatePayload = { allowed_cidrs: Array description?: string - mode: 'disabled' | 'enforce' | 'shadow' name: string } @@ -389,7 +388,6 @@ export type NetworkAccessGroupUpdatePayload = { allowed_cidrs: Array description?: string expected_version: number - mode: 'disabled' | 'enforce' | 'shadow' name: string } @@ -1319,15 +1317,17 @@ export type ProviderWithModelsResponse = { } export type NetworkAccessGroupResponse = { - allowed_cidrs?: Array + allowed_cidrs: Array + app_ids: Array + apps: Array created_at: string description?: string id: string - mode: 'disabled' | 'enforce' | 'shadow' name: string tenant_id: string updated_at: string updated_by_account_id?: string | null + used_by_count: number version: number } @@ -1916,6 +1916,14 @@ export type ProviderModelWithStatusEntity = { export type CustomConfigurationStatus = 'active' | 'no-configure' +export type NetworkAccessGroupAppResponse = { + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + name: string +} + export type TenantPluginAutoUpgradeStrategySetting = 'disabled' | 'fix_only' | 'latest' export type TenantPluginAutoUpgradeMode = 'all' | 'exclude' | 'partial' diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 0b2a2411b17988..0104a3f94113e5 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -251,9 +251,8 @@ export const zParserPreferredProviderType = z.object({ * NetworkAccessGroupCreatePayload */ export const zNetworkAccessGroupCreatePayload = z.object({ - allowed_cidrs: z.array(z.string()).max(100), + allowed_cidrs: z.array(z.string()).min(1).max(100), description: z.string().max(500).optional().default(''), - mode: z.enum(['disabled', 'enforce', 'shadow']), name: z.string().min(1).max(100), }) @@ -268,10 +267,9 @@ export const zNetworkAccessGroupDeleteResponse = z.object({ * NetworkAccessGroupUpdatePayload */ export const zNetworkAccessGroupUpdatePayload = z.object({ - allowed_cidrs: z.array(z.string()).max(100), + allowed_cidrs: z.array(z.string()).min(1).max(100), description: z.string().max(500).optional().default(''), expected_version: z.int().gte(1), - mode: z.enum(['disabled', 'enforce', 'shadow']), name: z.string().min(1).max(100), }) @@ -1013,38 +1011,6 @@ export const zCredentialConfiguration = z.object({ credential_name: z.string(), }) -/** - * NetworkAccessGroupResponse - */ -export const zNetworkAccessGroupResponse = z.object({ - allowed_cidrs: z.array(z.string()).optional(), - created_at: z.iso.datetime(), - description: z.string().optional().default(''), - id: z.string(), - mode: z.enum(['disabled', 'enforce', 'shadow']), - name: z.string(), - tenant_id: z.string(), - updated_at: z.iso.datetime(), - updated_by_account_id: z.string().nullish(), - version: z.int().gte(1), -}) - -/** - * NetworkAccessGroupListResponse - */ -export const zNetworkAccessGroupListResponse = z.object({ - entitled: z.boolean(), - groups: z.array(zNetworkAccessGroupResponse), - tenant_id: z.string(), -}) - -/** - * NetworkAccessGroupMutationResponse - */ -export const zNetworkAccessGroupMutationResponse = z.object({ - group: zNetworkAccessGroupResponse, -}) - /** * TenantPluginAutoUpgradeCategory */ @@ -1975,6 +1941,51 @@ export const zModelProviderSummaryListResponse = z.object({ plugins: z.record(z.string(), zModelProviderPluginSummaryResponse), }) +/** + * NetworkAccessGroupAppResponse + */ +export const zNetworkAccessGroupAppResponse = z.object({ + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + name: z.string(), +}) + +/** + * NetworkAccessGroupResponse + */ +export const zNetworkAccessGroupResponse = z.object({ + allowed_cidrs: z.array(z.string()).min(1).max(100), + app_ids: z.array(z.string()), + apps: z.array(zNetworkAccessGroupAppResponse), + created_at: z.iso.datetime(), + description: z.string().optional().default(''), + id: z.string(), + name: z.string(), + tenant_id: z.string(), + updated_at: z.iso.datetime(), + updated_by_account_id: z.string().nullish(), + used_by_count: z.int().gte(0), + version: z.int().gte(1), +}) + +/** + * NetworkAccessGroupListResponse + */ +export const zNetworkAccessGroupListResponse = z.object({ + entitled: z.boolean(), + groups: z.array(zNetworkAccessGroupResponse), + tenant_id: z.string(), +}) + +/** + * NetworkAccessGroupMutationResponse + */ +export const zNetworkAccessGroupMutationResponse = z.object({ + group: zNetworkAccessGroupResponse, +}) + /** * TenantPluginAutoUpgradeStrategySetting */ From c0bd8641395728f381fbbc49c2ae491872094e93 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 16:31:13 +0800 Subject: [PATCH 014/108] fix: harden network access policy compatibility --- .../console/workspace/network_access_group.py | 3 ++- api/controllers/console/wraps.py | 16 +++++++++--- api/services/network_access_group_service.py | 24 ++++++++++++------ .../controllers/console/test_wraps.py | 25 +++++++++++++++++++ .../workspace/test_network_access_group.py | 21 +++++++++++++--- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index eb294fc74c17ff..ab23dc0fbed666 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -33,7 +33,8 @@ AppMode.ADVANCED_CHAT: ("webapp", "service_api", "mcp"), AppMode.CHAT: ("webapp", "service_api", "mcp"), AppMode.COMPLETION: ("webapp", "service_api", "mcp"), - AppMode.AGENT_CHAT: ("webapp", "service_api"), + AppMode.AGENT_CHAT: ("webapp", "service_api", "mcp"), + AppMode.AGENT: ("webapp", "service_api"), } diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index f279a70f2ed362..152c4949895c17 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -5,10 +5,11 @@ from functools import wraps from typing import Any, Concatenate, Protocol, cast, overload +import httpx from flask import abort, request from pydantic import BaseModel, ValidationError from sqlalchemy import select -from werkzeug.exceptions import Forbidden, UnprocessableEntity +from werkzeug.exceptions import Forbidden, ServiceUnavailable, UnprocessableEntity from configs import dify_config from controllers.common.wraps import ( @@ -27,7 +28,7 @@ from models.account import AccountStatus from models.dataset import RateLimitLog from models.model import DifySetup -from services.billing_service import BillingService +from services.billing_service import BillingService, _BillingHTTPStatusError from services.entities.feature_entities import LicenseStatus from services.feature_service import FeatureService from services.operation_service import OperationService, UtmInfo @@ -167,7 +168,16 @@ def is_cloud_edition_billing_paid_plan(tenant_id: str) -> bool: render an upgrade state. """ - billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + try: + billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + except ( + _BillingHTTPStatusError, + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + ) as exc: + raise ServiceUnavailable("Billing entitlement is temporarily unavailable.") from exc return billing_info["enabled"] and billing_info["subscription"]["plan"] in ( CloudPlan.PROFESSIONAL, CloudPlan.TEAM, diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 02c3685a37a118..59e6c5652cd667 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -1,10 +1,14 @@ +import logging from typing import Any from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError from extensions.ext_database import db from models import App +logger = logging.getLogger(__name__) + class NetworkAccessGroupService: """Dify-side orchestration for SaaS network access group responses.""" @@ -51,13 +55,19 @@ def enrich_app_references( group["apps"] = [] return payload - app_models = db.session.scalars( - select(App).where( - App.tenant_id == tenant_id, - App.id.in_(app_ids), - App.status == "normal", - ) - ).all() + try: + app_models = db.session.scalars( + select(App).where( + App.tenant_id == tenant_id, + App.id.in_(app_ids), + App.status == "normal", + ) + ).all() + except SQLAlchemyError: + logger.exception("Failed to enrich network access Policy App references", extra={"tenant_id": tenant_id}) + for group in groups: + group["apps"] = [] + return payload apps_by_id = { str(app.id): { "id": str(app.id), diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 188e5884526fd6..eaa655231aad1b 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -2,6 +2,7 @@ from typing import override from unittest.mock import MagicMock, patch +import httpx import pytest from flask import Flask, request from flask_login import LoginManager, UserMixin @@ -776,6 +777,30 @@ def paid_view(): assert exc_info.value.code == 403 assert "requires a paid plan" in str(exc_info.value.description) + def test_should_return_service_unavailable_when_billing_lookup_fails(self): + app = create_app_with_login() + + @cloud_edition_billing_paid_plan_required + def paid_view(): + return "paid_success" + + with app.test_request_context(): + with ( + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(MockUser("test_user"), "tenant123"), + ), + patch( + "controllers.console.wraps.BillingService.get_info", + side_effect=httpx.ConnectError("billing unavailable"), + ), + pytest.raises(HTTPException) as exc_info, + ): + paid_view() + + assert exc_info.value.code == 503 + assert "temporarily unavailable" in str(exc_info.value.description) + class TestBillingResourceLimits: """Test billing resource limit decorators""" diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 42e09bbe5036af..74e74a2ca6b489 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -8,6 +8,7 @@ import pytest from pydantic import ValidationError +from sqlalchemy.exc import SQLAlchemyError from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable from controllers.console.workspace.network_access_group import ( @@ -460,7 +461,8 @@ def test_binding_payload_rejects_invalid_group_id_and_version() -> None: (AppMode.ADVANCED_CHAT, ["webapp", "service_api", "mcp"]), (AppMode.CHAT, ["webapp", "service_api", "mcp"]), (AppMode.COMPLETION, ["webapp", "service_api", "mcp"]), - (AppMode.AGENT_CHAT, ["webapp", "service_api"]), + (AppMode.AGENT_CHAT, ["webapp", "service_api", "mcp"]), + (AppMode.AGENT, ["webapp", "service_api"]), ], ) def test_available_access_points_are_derived_from_app_mode(app_mode: AppMode, expected: list[str]) -> None: @@ -469,7 +471,7 @@ def test_available_access_points_are_derived_from_app_mode(app_mode: AppMode, ex assert _available_access_points(cast(App, app_model)) == expected -@pytest.mark.parametrize("app_mode", [AppMode.AGENT, AppMode.CHANNEL, AppMode.RAG_PIPELINE]) +@pytest.mark.parametrize("app_mode", [AppMode.CHANNEL, AppMode.RAG_PIPELINE]) def test_unsupported_app_modes_are_rejected(app_mode: AppMode) -> None: with pytest.raises(BadRequest, match="not supported"): _available_access_points(cast(App, SimpleNamespace(mode=app_mode))) @@ -478,7 +480,7 @@ def test_unsupported_app_modes_are_rejected(app_mode: AppMode) -> None: def test_app_put_rejects_access_point_not_available_for_mode_before_upstream_call() -> None: api = AppNetworkAccessGroupApi() method = unwrap(api.put) - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.AGENT_CHAT) + app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.AGENT) request_payload = AppNetworkAccessGroupUpdatePayload( enabled=True, group_id=GROUP_ID, @@ -567,6 +569,19 @@ def test_group_responses_are_enriched_with_tenant_scoped_app_metadata() -> None: db_mock.session.scalars.assert_called_once() +def test_group_app_enrichment_degrades_to_ids_when_core_database_is_unavailable() -> None: + payload = {"group": _group_payload(app_ids=[APP_ID])} + db_mock = MagicMock() + db_mock.session.scalars.side_effect = SQLAlchemyError("database unavailable") + + with patch("services.network_access_group_service.db", db_mock): + result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID) + + assert result["group"]["app_ids"] == [APP_ID] + assert result["group"]["used_by_count"] == 1 + assert result["group"]["apps"] == [] + + def test_sandbox_read_returns_not_entitled_for_upgrade_state() -> None: api = CurrentWorkspaceNetworkAccessGroupsApi() method = unwrap(api.get) From 49b8284345b372b527a13488980a657ebab2d719 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 16:34:35 +0800 Subject: [PATCH 015/108] fix: normalize app access points by mode --- .../console/workspace/network_access_group.py | 15 +++++++++++++++ .../workspace/test_network_access_group.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index ab23dc0fbed666..1922a67327b29d 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -217,6 +217,19 @@ def _normalize_app_binding_defaults(payload: dict) -> None: binding["access_points"] = [] +def _limit_binding_access_points(payload: dict, available: list[NetworkAccessPoint]) -> None: + """Hide stale scopes which are not real access points for the current App mode.""" + + binding = payload.get("binding") + if not isinstance(binding, dict): + return + raw_access_points = binding.get("access_points", binding.get("accessPoints", [])) + if not isinstance(raw_access_points, list): + return + binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available] + binding.pop("accessPoints", None) + + def _ensure_workspace_admin_or_owner(current_user: Account) -> None: """Authorize management against the persisted current-workspace membership.""" @@ -431,6 +444,7 @@ def get(self, current_tenant_id: str, current_user: Account, app_model: App): except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc _normalize_app_binding_defaults(payload) + _limit_binding_access_points(payload, available_access_points) payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled")) payload["available_access_points"] = available_access_points return _serialize_response(AppNetworkAccessGroupResponse, payload) @@ -473,5 +487,6 @@ def put( except NetworkAccessGroupUpstreamError as exc: raise _translate_upstream_error(exc) from exc _normalize_app_binding_defaults(payload) + _limit_binding_access_points(payload, available_access_points) payload["available_access_points"] = available_access_points return _serialize_response(AppNetworkAccessGroupMutationResponse, payload) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 74e74a2ca6b489..62b6ab93a2dcb9 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -21,6 +21,7 @@ NetworkAccessGroupUpdatePayload, _available_access_points, _effective_entitlement, + _limit_binding_access_points, _translate_upstream_error, ) from models import App, AppMode, TenantAccountRole @@ -429,6 +430,21 @@ def test_binding_response_accepts_policy_id_alias() -> None: assert result["binding"]["group_id"] == GROUP_ID +def test_binding_response_hides_scopes_not_available_for_current_app_mode() -> None: + payload = { + "binding": _binding_payload( + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api", "mcp", "trigger"], + ) + } + + _limit_binding_access_points(payload, ["webapp", "service_api"]) + + assert payload["binding"]["access_points"] == ["webapp", "service_api"] + assert "accessPoints" not in payload["binding"] + + @pytest.mark.parametrize( "payload", [ From 5ec3f43bb76df853525a5814afdaf4d75f404644 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 16:46:02 +0800 Subject: [PATCH 016/108] fix: align paid plan entitlement contract --- api/controllers/console/wraps.py | 2 +- api/tests/unit_tests/controllers/console/test_wraps.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 152c4949895c17..4c1c0cb7c65212 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -178,7 +178,7 @@ def is_cloud_edition_billing_paid_plan(tenant_id: str) -> bool: ValidationError, ) as exc: raise ServiceUnavailable("Billing entitlement is temporarily unavailable.") from exc - return billing_info["enabled"] and billing_info["subscription"]["plan"] in ( + return billing_info["subscription"]["plan"] in ( CloudPlan.PROFESSIONAL, CloudPlan.TEAM, ) diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index eaa655231aad1b..3479c09ce701a8 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -751,18 +751,15 @@ def paid_view(): assert result == "paid_success" get_info.assert_called_once_with("tenant123", exclude_vector_space=True) - @pytest.mark.parametrize( - ("enabled", "plan"), - [(False, "professional"), (True, "sandbox"), (True, "unknown")], - ) - def test_should_reject_non_paid_plan(self, enabled: bool, plan: str): + @pytest.mark.parametrize("plan", ["sandbox", "unknown"]) + def test_should_reject_non_paid_plan(self, plan: str): app = create_app_with_login() @cloud_edition_billing_paid_plan_required def paid_view(): return "paid_success" - billing_info = {"enabled": enabled, "subscription": {"plan": plan}} + billing_info = {"enabled": True, "subscription": {"plan": plan}} with app.test_request_context(): with ( patch( From 1d9862b0739ce21f002d98900a246648821e8b8f Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 17:41:39 +0800 Subject: [PATCH 017/108] fix: separate network access API URL --- api/configs/feature/__init__.py | 6 ++ api/services/billing_service.py | 4 +- ...st_network_access_group_billing_service.py | 62 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index c3f1be5d045c25..ad35f07b6f9c38 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -410,6 +410,12 @@ class EndpointConfig(BaseSettings): TRIGGER_URL: str = Field(description="Template url for triggers", default="http://localhost:5001") + NETWORK_ACCESS_API_URL: str = Field( + description="Optional internal SaaS API base URL for network access policy requests; " + "falls back to BILLING_API_URL", + default="", + ) + class FileAccessConfig(BaseSettings): """ diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 43a58acc525c65..82bb9fed8b917d 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -9,6 +9,7 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed from werkzeug.exceptions import InternalServerError +from configs import dify_config from core.helper.http_client_pooling import get_pooled_http_client from enums import CloudPlan from extensions.ext_redis import redis_client @@ -638,9 +639,10 @@ def _send_network_access_group_http_request( params: dict[str, Any] | None = None, ) -> httpx.Response: headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} + network_access_base_url = dify_config.NETWORK_ACCESS_API_URL.strip() or cls.base_url return _http_client.request( method, - f"{cls.base_url}{endpoint}", + f"{network_access_base_url.rstrip('/')}{endpoint}", json=payload_json, params=params, headers=headers, diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py index d24297efe09870..d2c95ae0ae22f1 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -4,6 +4,7 @@ import httpx import pytest +from configs import dify_config from services.billing_service import BillingService, NetworkAccessGroupUpstreamError TENANT_ID = "11111111-1111-4111-8111-111111111111" @@ -18,10 +19,71 @@ def billing_config() -> Iterator[None]: with ( patch.object(BillingService, "base_url", "https://billing.internal/v1"), patch.object(BillingService, "secret_key", "test-secret"), + patch.object(dify_config, "NETWORK_ACCESS_API_URL", ""), ): yield +@pytest.mark.usefixtures("billing_config") +def test_network_access_requests_use_independent_api_url_when_configured() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": []} + + with ( + patch.object(dify_config, "NETWORK_ACCESS_API_URL", "https://saas.internal/v1/"), + patch("services.billing_service._http_client.request", return_value=response) as request, + ): + BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) + + request.assert_called_once_with( + "GET", + f"https://saas.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ) + + +@pytest.mark.usefixtures("billing_config") +def test_network_access_requests_fall_back_to_billing_api_url_at_request_time() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": []} + + with patch("services.billing_service._http_client.request", return_value=response) as request: + BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) + + request.assert_called_once_with( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ) + + +@pytest.mark.usefixtures("billing_config") +def test_subscription_requests_keep_using_billing_api_url() -> None: + response = MagicMock(status_code=httpx.codes.OK) + response.json.return_value = {"subscription": {"plan": "professional"}} + + with ( + patch.object(dify_config, "NETWORK_ACCESS_API_URL", "https://saas.internal/v1"), + patch("services.billing_service._http_client.request", return_value=response) as request, + ): + BillingService._send_request("GET", "/subscription/info", params={"tenant_id": TENANT_ID}) + + request.assert_called_once_with( + "GET", + "https://billing.internal/v1/subscription/info", + json=None, + params={"tenant_id": TENANT_ID}, + headers=HEADERS, + follow_redirects=True, + ) + + @pytest.mark.usefixtures("billing_config") def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None: response = MagicMock(status_code=httpx.codes.OK) From 1c3d5fce9e752f8d81e955865b30721a1d716c61 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 18:27:53 +0800 Subject: [PATCH 018/108] fix: avoid dynamic icon type lookup --- api/services/network_access_group_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 59e6c5652cd667..dcbe2e8908639c 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -73,7 +73,7 @@ def enrich_app_references( "id": str(app.id), "name": app.name, "icon": app.icon, - "icon_type": getattr(app.icon_type, "value", app.icon_type) if app.icon_type is not None else None, + "icon_type": app.icon_type.value if app.icon_type is not None else None, "icon_background": app.icon_background, } for app in app_models From 4f82c193bc61b3de6928015b2c13c71220735a53 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Wed, 9 Sep 2026 18:42:01 +0800 Subject: [PATCH 019/108] test: use validated config overrides --- ...st_network_access_group_billing_service.py | 25 ++++++++----------- .../test_remove_app_and_related_data_task.py | 12 ++++----- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_billing_service.py index e4bdf9763cbabf..c034f21904df60 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_billing_service.py @@ -1,10 +1,9 @@ -from collections.abc import Iterator +from collections.abc import Callable, Iterator from unittest.mock import MagicMock, call, patch import httpx import pytest -from configs import dify_config from services.billing_service import BillingService, NetworkAccessGroupUpstreamError TENANT_ID = "11111111-1111-4111-8111-111111111111" @@ -15,24 +14,24 @@ @pytest.fixture -def billing_config() -> Iterator[None]: +def billing_config(config_overrides: Callable[..., None]) -> Iterator[None]: + config_overrides(NETWORK_ACCESS_API_URL="") with ( patch.object(BillingService, "base_url", "https://billing.internal/v1"), patch.object(BillingService, "secret_key", "test-secret"), - patch.object(dify_config, "NETWORK_ACCESS_API_URL", ""), ): yield @pytest.mark.usefixtures("billing_config") -def test_network_access_requests_use_independent_api_url_when_configured() -> None: +def test_network_access_requests_use_independent_api_url_when_configured( + config_overrides: Callable[..., None], +) -> None: response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()} - with ( - patch.object(dify_config, "NETWORK_ACCESS_API_URL", "https://saas.internal/v1/"), - patch("services.billing_service._http_client.request", return_value=response) as request, - ): + config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1/") + with patch("services.billing_service._http_client.request", return_value=response) as request: BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) request.assert_called_once_with( @@ -64,14 +63,12 @@ def test_network_access_requests_fall_back_to_billing_api_url_at_request_time() @pytest.mark.usefixtures("billing_config") -def test_subscription_requests_keep_using_billing_api_url() -> None: +def test_subscription_requests_keep_using_billing_api_url(config_overrides: Callable[..., None]) -> None: response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = {"subscription": {"plan": "professional"}} - with ( - patch.object(dify_config, "NETWORK_ACCESS_API_URL", "https://saas.internal/v1"), - patch("services.billing_service._http_client.request", return_value=response) as request, - ): + config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1") + with patch("services.billing_service._http_client.request", return_value=response) as request: BillingService._send_request("GET", "/subscription/info", params={"tenant_id": TENANT_ID}) request.assert_called_once_with( diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 2da72e3833adab..e489c0598c5292 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -122,7 +122,7 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest def test_cloud_app_cleanup_schedules_independent_binding_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: apply_async = MagicMock() - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( remove_app_task_module.cleanup_app_network_access_group_binding_task, "apply_async", @@ -139,7 +139,7 @@ def test_cloud_app_cleanup_schedules_independent_binding_cleanup(monkeypatch: py def test_community_app_cleanup_does_not_schedule_binding_cleanup(monkeypatch: pytest.MonkeyPatch) -> None: apply_async = MagicMock() - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) monkeypatch.setattr( remove_app_task_module.cleanup_app_network_access_group_binding_task, "apply_async", @@ -153,7 +153,7 @@ def test_community_app_cleanup_does_not_schedule_binding_cleanup(monkeypatch: py def test_independent_binding_cleanup_task_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = MagicMock(return_value={"deleted": True}) - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( remove_app_task_module.BillingService, "cleanup_app_network_access_group_binding", @@ -172,7 +172,7 @@ def test_independent_binding_cleanup_task_retries_with_exponential_backoff( upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_APP_STILL_EXISTS") retry_error = RuntimeError("retry scheduled") retry = MagicMock(side_effect=retry_error) - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( remove_app_task_module.BillingService, "cleanup_app_network_access_group_binding", @@ -196,7 +196,7 @@ def test_parent_local_cleanup_failure_happens_after_binding_cleanup_is_scheduled retry_error = RuntimeError("parent retry scheduled") apply_async = MagicMock() retry = MagicMock(side_effect=retry_error) - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( remove_app_task_module.cleanup_app_network_access_group_binding_task, "apply_async", @@ -220,7 +220,7 @@ def test_parent_retries_before_local_cleanup_when_binding_schedule_fails(monkeyp retry_error = RuntimeError("parent retry scheduled") retry = MagicMock(side_effect=retry_error) first_local_cleanup = MagicMock() - monkeypatch.setattr(remove_app_task_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) monkeypatch.setattr( remove_app_task_module.cleanup_app_network_access_group_binding_task, "apply_async", From e8b965a3c3dc9b52f7ade7d56191ed4d71ece818 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Thu, 10 Sep 2026 14:25:38 +0800 Subject: [PATCH 020/108] refactor: migrate network access group APIs --- api/.importlinter | 15 + .../console/workspace/network_access_group.py | 284 +++------ api/extensions/ext_application_services.py | 17 + .../network_access_group_repository.py | 76 +++ .../workspace_query_repository.py | 9 + api/services/billing_service.py | 225 ------- api/services/network_access_group_gateway.py | 235 ++++++++ api/services/network_access_group_service.py | 366 ++++++++++- api/tasks/remove_app_and_related_data_task.py | 10 +- .../workspace/test_network_access_group.py | 570 +++++------------- .../test_ext_application_services.py | 24 + .../test_network_access_group_repository.py | 169 ++++++ .../test_workspace_query_repository.py | 58 ++ ...y => test_network_access_group_gateway.py} | 274 +++++---- .../test_network_access_group_service.py | 444 ++++++++++++++ .../test_remove_app_and_related_data_task.py | 42 +- 16 files changed, 1836 insertions(+), 982 deletions(-) create mode 100644 api/repositories/network_access_group_repository.py create mode 100644 api/services/network_access_group_gateway.py create mode 100644 api/tests/unit_tests/repositories/test_network_access_group_repository.py create mode 100644 api/tests/unit_tests/repositories/test_workspace_query_repository.py rename api/tests/unit_tests/services/{test_network_access_group_billing_service.py => test_network_access_group_gateway.py} (54%) create mode 100644 api/tests/unit_tests/services/test_network_access_group_service.py diff --git a/api/.importlinter b/api/.importlinter index d9796d56504ba8..9a0005a5703062 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -171,6 +171,21 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:network-access-group-service-boundary] +name = Network access group application service is framework and persistence neutral +type = forbidden +source_modules = + services.network_access_group_service +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + werkzeug + [importlinter:contract:init-validation-service-boundary] name = Initialization validation application service is framework and persistence neutral type = forbidden diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index 1922a67327b29d..6d5ba151c52614 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Literal, Self +from typing import Any, Self from uuid import UUID from flask_restx import Resource @@ -8,34 +8,24 @@ from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns -from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import ( - account_initialization_required, - cloud_edition_billing_paid_plan_required, - is_cloud_edition_billing_paid_plan, - model_validate, - only_edition_cloud, - setup_required, - with_current_tenant_id, - with_current_user, -) +from controllers.console.app.error import AppNotFoundError +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import cloud_edition_billing_paid_plan_required, model_validate +from enums import DeploymentEdition +from extensions.ext_application_services import application_services from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import login_required -from models import Account, App, AppMode, TenantAccountRole -from services.billing_service import BillingService, NetworkAccessGroupUpstreamError -from services.network_access_group_service import NetworkAccessGroupService - -NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"] - -_ACCESS_POINTS_BY_APP_MODE: dict[AppMode, tuple[NetworkAccessPoint, ...]] = { - AppMode.WORKFLOW: ("webapp", "service_api", "mcp", "trigger"), - AppMode.ADVANCED_CHAT: ("webapp", "service_api", "mcp"), - AppMode.CHAT: ("webapp", "service_api", "mcp"), - AppMode.COMPLETION: ("webapp", "service_api", "mcp"), - AppMode.AGENT_CHAT: ("webapp", "service_api", "mcp"), - AppMode.AGENT: ("webapp", "service_api"), -} +from machinery.context import RequestContext +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupError, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, + NetworkAccessPoint, +) class NetworkAccessGroupCreatePayload(BaseModel): @@ -184,59 +174,6 @@ class AppNetworkAccessGroupMutationResponse(ResponseModel): ) -def _available_access_points(app_model: App) -> list[NetworkAccessPoint]: - app_mode = app_model.mode if isinstance(app_model.mode, AppMode) else AppMode.value_of(str(app_model.mode)) - available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode) - if available is None: - raise BadRequest(f"Network access control is not supported for app mode '{app_mode.value}'.") - return list(available) - - -def _validate_app_access_points( - requested: list[NetworkAccessPoint], - available: list[NetworkAccessPoint], -) -> None: - unsupported = sorted(set(requested).difference(available)) - if unsupported: - raise BadRequest(f"Unsupported access points for this app: {', '.join(unsupported)}.") - - -def _effective_entitlement(tenant_id: str, upstream_entitled: object) -> bool: - return bool(upstream_entitled) and is_cloud_edition_billing_paid_plan(tenant_id) - - -def _normalize_app_binding_defaults(payload: dict) -> None: - """Materialize ProtoJSON defaults so the Console response stays stable.""" - - payload.setdefault("binding", None) - binding = payload.get("binding") - if not isinstance(binding, dict): - return - binding.setdefault("enabled", False) - if "access_points" not in binding and "accessPoints" not in binding: - binding["access_points"] = [] - - -def _limit_binding_access_points(payload: dict, available: list[NetworkAccessPoint]) -> None: - """Hide stale scopes which are not real access points for the current App mode.""" - - binding = payload.get("binding") - if not isinstance(binding, dict): - return - raw_access_points = binding.get("access_points", binding.get("accessPoints", [])) - if not isinstance(raw_access_points, list): - return - binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available] - binding.pop("accessPoints", None) - - -def _ensure_workspace_admin_or_owner(current_user: Account) -> None: - """Authorize management against the persisted current-workspace membership.""" - - if not TenantAccountRole.is_privileged_role(current_user.current_role): - raise Forbidden("Only workspace owners and administrators can manage network access groups.") - - def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception: if exc.reason == "INVALID_SECRET_KEY": return ServiceUnavailable("Network access group service authentication failed.") @@ -260,7 +197,23 @@ def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception return BadGateway("Unexpected response from the network access group service.") -def _serialize_response(response_model: type[ResponseModel], payload: dict): +def _translate_service_error(exc: NetworkAccessGroupError) -> Exception: + if isinstance(exc, NetworkAccessGroupUpstreamError): + return _translate_upstream_error(exc) + if isinstance(exc, NetworkAccessGroupAccessDeniedError): + return Forbidden("Only workspace owners and administrators can manage network access groups.") + if isinstance(exc, NetworkAccessGroupEntitlementUnavailableError): + return ServiceUnavailable("Billing entitlement is temporarily unavailable.") + if isinstance(exc, NetworkAccessGroupAppNotFoundError): + return AppNotFoundError() + if isinstance(exc, NetworkAccessGroupUnsupportedAppModeError): + return BadRequest(f"Network access control is not supported for app mode '{exc.app_mode}'.") + if isinstance(exc, NetworkAccessGroupUnsupportedAccessPointsError): + return BadRequest(f"Unsupported access points for this app: {', '.join(exc.access_points)}.") + raise TypeError(f"Unsupported network access group error: {type(exc).__name__}") + + +def _serialize_response(response_model: type[ResponseModel], payload: dict[str, Any]) -> dict[str, Any]: try: return dump_response(response_model, payload) except ValidationError as exc: @@ -274,20 +227,12 @@ class CurrentWorkspaceNetworkAccessGroupsApi(Resource): "Workspace network access groups retrieved successfully", console_ns.models[NetworkAccessGroupListResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account): - _ensure_workspace_admin_or_owner(current_user) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext) -> dict[str, Any]: try: - payload = BillingService.list_network_access_groups(current_tenant_id, current_user.id) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled")) - NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) + payload = application_services().network_access_groups.list_groups(request_context) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(NetworkAccessGroupListResponse, payload) @console_ns.expect(console_ns.models[NetworkAccessGroupCreatePayload.__name__]) @@ -296,32 +241,23 @@ def get(self, current_tenant_id: str, current_user: Account): "Workspace network access group created successfully", console_ns.models[NetworkAccessGroupMutationResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @cloud_edition_billing_paid_plan_required - @with_current_user - @with_current_tenant_id @model_validate(NetworkAccessGroupCreatePayload) def post( self, req_data: NetworkAccessGroupCreatePayload, - current_tenant_id: str, - current_user: Account, - ): - _ensure_workspace_admin_or_owner(current_user) + request_context: RequestContext, + ) -> tuple[dict[str, Any], int]: try: - payload = BillingService.create_network_access_group( - current_tenant_id, + payload = application_services().network_access_groups.create_group( + request_context, name=req_data.name, description=req_data.description, allowed_cidrs=req_data.allowed_cidrs, - actor_account_id=current_user.id, ) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(NetworkAccessGroupMutationResponse, payload), 201 @@ -332,19 +268,15 @@ class CurrentWorkspaceNetworkAccessGroupApi(Resource): "Workspace network access group retrieved successfully", console_ns.models[NetworkAccessGroupMutationResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud - @with_current_user - @with_current_tenant_id - def get(self, current_tenant_id: str, current_user: Account, group_id: UUID): - _ensure_workspace_admin_or_owner(current_user) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, group_id: UUID) -> dict[str, Any]: try: - payload = BillingService.get_network_access_group(current_tenant_id, str(group_id), current_user.id) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) + payload = application_services().network_access_groups.get_group( + request_context, + group_id=str(group_id), + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(NetworkAccessGroupMutationResponse, payload) @console_ns.expect(console_ns.models[NetworkAccessGroupUpdatePayload.__name__]) @@ -353,35 +285,26 @@ def get(self, current_tenant_id: str, current_user: Account, group_id: UUID): "Workspace network access group updated successfully", console_ns.models[NetworkAccessGroupMutationResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @cloud_edition_billing_paid_plan_required - @with_current_user - @with_current_tenant_id @model_validate(NetworkAccessGroupUpdatePayload) def put( self, req_data: NetworkAccessGroupUpdatePayload, - current_tenant_id: str, - current_user: Account, + request_context: RequestContext, group_id: UUID, - ): - _ensure_workspace_admin_or_owner(current_user) + ) -> dict[str, Any]: try: - payload = BillingService.update_network_access_group( - current_tenant_id, - str(group_id), + payload = application_services().network_access_groups.update_group( + request_context, + group_id=str(group_id), name=req_data.name, description=req_data.description, allowed_cidrs=req_data.allowed_cidrs, expected_version=req_data.expected_version, - actor_account_id=current_user.id, ) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(NetworkAccessGroupMutationResponse, payload) @console_ns.doc(params=query_params_from_model(NetworkAccessGroupDeleteQuery)) @@ -390,31 +313,23 @@ def put( "Workspace network access group deleted successfully", console_ns.models[NetworkAccessGroupDeleteResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @cloud_edition_billing_paid_plan_required - @with_current_user - @with_current_tenant_id @model_validate(NetworkAccessGroupDeleteQuery) def delete( self, req_data: NetworkAccessGroupDeleteQuery, - current_tenant_id: str, - current_user: Account, + request_context: RequestContext, group_id: UUID, - ): - _ensure_workspace_admin_or_owner(current_user) + ) -> dict[str, Any]: try: - payload = BillingService.delete_network_access_group( - current_tenant_id, - str(group_id), + payload = application_services().network_access_groups.delete_group( + request_context, + group_id=str(group_id), expected_version=req_data.expected_version, - actor_account_id=current_user.id, ) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(NetworkAccessGroupDeleteResponse, payload) @@ -425,28 +340,15 @@ class AppNetworkAccessGroupApi(Resource): "App network access group binding retrieved successfully", console_ns.models[AppNetworkAccessGroupResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud - @with_current_user - @with_current_tenant_id - @get_app_model - def get(self, current_tenant_id: str, current_user: Account, app_model: App): - _ensure_workspace_admin_or_owner(current_user) - available_access_points = _available_access_points(app_model) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, Any]: try: - payload = BillingService.get_app_network_access_group( - current_tenant_id, - str(app_model.id), - current_user.id, + payload = application_services().network_access_groups.get_app_binding( + request_context, + app_id=str(app_id), ) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - _normalize_app_binding_defaults(payload) - _limit_binding_access_points(payload, available_access_points) - payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled")) - payload["available_access_points"] = available_access_points + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(AppNetworkAccessGroupResponse, payload) @console_ns.expect(console_ns.models[AppNetworkAccessGroupUpdatePayload.__name__]) @@ -455,38 +357,24 @@ def get(self, current_tenant_id: str, current_user: Account, app_model: App): "App network access group binding updated successfully", console_ns.models[AppNetworkAccessGroupMutationResponse.__name__], ) - @setup_required - @login_required - @account_initialization_required - @only_edition_cloud + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) @cloud_edition_billing_paid_plan_required - @with_current_user - @with_current_tenant_id - @get_app_model @model_validate(AppNetworkAccessGroupUpdatePayload) def put( self, req_data: AppNetworkAccessGroupUpdatePayload, - current_tenant_id: str, - current_user: Account, - app_model: App, - ): - _ensure_workspace_admin_or_owner(current_user) - available_access_points = _available_access_points(app_model) - _validate_app_access_points(req_data.access_points, available_access_points) + request_context: RequestContext, + app_id: UUID, + ) -> dict[str, Any]: try: - payload = BillingService.update_app_network_access_group( - current_tenant_id, - str(app_model.id), + payload = application_services().network_access_groups.update_app_binding( + request_context, + app_id=str(app_id), enabled=req_data.enabled, group_id=str(req_data.group_id) if req_data.group_id is not None else None, access_points=list(req_data.access_points), expected_version=req_data.expected_version, - actor_account_id=current_user.id, ) - except NetworkAccessGroupUpstreamError as exc: - raise _translate_upstream_error(exc) from exc - _normalize_app_binding_defaults(payload) - _limit_binding_access_points(payload, available_access_points) - payload["available_access_points"] = available_access_points + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc return _serialize_response(AppNetworkAccessGroupMutationResponse, payload) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 07aa6edc21e1db..e0099f82171715 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -50,6 +50,7 @@ from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository from repositories.installation_state_repository import InstallationStateRepository from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository from repositories.oauth_access_token_repository import SQLAlchemyOAuthAccessTokenRepository from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository @@ -163,6 +164,11 @@ from services.init_validation_service import InitValidationService from services.inner_mail_service import InnerMailService from services.message_file_preview_service import MessageFilePreviewService +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import NetworkAccessGroupService from services.notification_gateway import BillingNotificationGateway from services.notification_service import NotificationService from services.notion_data_source_gateway import NotionDataSourceGateway @@ -278,6 +284,7 @@ class ApplicationServices: oauth_server: OAuthServerService init_validation: InitValidationService notifications: NotificationService + network_access_groups: NetworkAccessGroupService step_by_step_tour: StepByStepTourService partner_tenant_bindings: PartnerTenantBindingService recommended_app_queries: RecommendedAppQueryService @@ -696,6 +703,16 @@ def build_application_services( notifications=NotificationService( notifications=BillingNotificationGateway(), ), + network_access_groups=NetworkAccessGroupService( + control_plane=NetworkAccessGroupGateway( + base_url=dify_config.NETWORK_ACCESS_API_URL, + fallback_base_url=BillingService.base_url, + secret_key=BillingService.secret_key, + ), + apps=SQLAlchemyNetworkAccessGroupAppRepository(session_factory=database_client), + memberships=workspace_query_repository, + entitlement=BillingNetworkAccessGroupEntitlementGateway(), + ), step_by_step_tour=StepByStepTourService( accounts=accounts, states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client), diff --git a/api/repositories/network_access_group_repository.py b/api/repositories/network_access_group_repository.py new file mode 100644 index 00000000000000..942b7987307bcc --- /dev/null +++ b/api/repositories/network_access_group_repository.py @@ -0,0 +1,76 @@ +"""SQLAlchemy read repository for network access group App projections.""" + +from collections.abc import Sequence +from typing import override + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from models import App +from models.agent import AgentScope +from services.network_access_group_service import ( + NetworkAccessGroupAppQuery, + NetworkAccessGroupAppQueryError, + NetworkAccessGroupAppRecord, +) + + +class SQLAlchemyNetworkAccessGroupAppRepository(NetworkAccessGroupAppQuery): + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def get_manageable_app(self, *, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord | None: + try: + with self._session_factory() as session: + app = session.scalar( + select(App) + .where( + App.id == app_id, + App.tenant_id == workspace_id, + App.status == "normal", + ) + .limit(1) + ) + if app is None: + return None + binding = app.agent_app_binding_with_session(session=session, include_archived=True) + if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY: + return None + return self._to_record(app) + except SQLAlchemyError as exc: + raise NetworkAccessGroupAppQueryError from exc + + @override + def list_apps( + self, + *, + workspace_id: str, + app_ids: Sequence[str], + ) -> tuple[NetworkAccessGroupAppRecord, ...]: + if not app_ids: + return () + try: + with self._session_factory() as session: + apps = session.scalars( + select(App).where( + App.tenant_id == workspace_id, + App.id.in_(app_ids), + App.status == "normal", + ) + ).all() + return tuple(self._to_record(app) for app in apps) + except SQLAlchemyError as exc: + raise NetworkAccessGroupAppQueryError from exc + + @staticmethod + def _to_record(app: App) -> NetworkAccessGroupAppRecord: + return NetworkAccessGroupAppRecord( + id=str(app.id), + mode=app.mode.value, + name=app.name, + icon=app.icon, + icon_type=app.icon_type.value if app.icon_type is not None else None, + icon_background=app.icon_background, + ) diff --git a/api/repositories/workspace_query_repository.py b/api/repositories/workspace_query_repository.py index 8e83d158c478dc..f31af9c7acd46d 100644 --- a/api/repositories/workspace_query_repository.py +++ b/api/repositories/workspace_query_repository.py @@ -58,6 +58,15 @@ def list_ids_for_account(self, account_id: str) -> tuple[str, ...]: with self._session_factory() as session: return tuple(session.scalars(stmt).all()) + def get_role_for_account(self, *, workspace_id: str, account_id: str) -> str | None: + stmt = select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == workspace_id, + TenantAccountJoin.account_id == account_id, + ) + with self._session_factory() as session: + role = session.scalar(stmt) + return role.value if role is not None else None + @override def list_account_access_workspaces(self, account_id: str) -> tuple[AccountWorkspaceSnapshot, ...]: """List every membership for the OpenAPI account identity response. diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 79cdc89df5ff2e..c16f5b5142cf35 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -10,7 +10,6 @@ from typing_extensions import deprecated from werkzeug.exceptions import InternalServerError -from configs import dify_config from core.helper.http_client_pooling import get_pooled_http_client from enums import CloudPlan from extensions.ext_redis import redis_client @@ -216,15 +215,6 @@ class DismissNotificationDict(TypedDict): success: bool -class NetworkAccessGroupUpstreamError(Exception): - """Failure returned by the SaaS network-access-group control plane.""" - - def __init__(self, status_code: int, reason: str | None = None): - self.status_code = status_code - self.reason = reason - super().__init__(f"network access group upstream returned HTTP {status_code}") - - class BillingService: base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL") quota_base_url = os.environ.get("BILLING_QUOTA_API_URL") or base_url @@ -443,221 +433,6 @@ def get_tenant_feature_plan_usage(cls, tenant_id: str, feature_key: str): params = {"tenant_id": tenant_id, "feature_key": feature_key} return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params) - @classmethod - def list_network_access_groups(cls, tenant_id: str, actor_account_id: str) -> dict[str, Any]: - """List the reusable network access groups owned by one workspace.""" - - return cls._send_network_access_group_request( - "GET", - f"/tenants/{tenant_id}/network-access-groups", - params={"actor_account_id": actor_account_id}, - ) - - @classmethod - def create_network_access_group( - cls, - tenant_id: str, - *, - name: str, - description: str, - allowed_cidrs: list[str], - actor_account_id: str, - ) -> dict[str, Any]: - """Create a reusable group; the Console BFF injects the authenticated actor.""" - - return cls._send_network_access_group_request( - "POST", - f"/tenants/{tenant_id}/network-access-groups", - payload_json={ - "name": name, - "description": description, - "allowed_cidrs": allowed_cidrs, - "actor_account_id": actor_account_id, - }, - ) - - @classmethod - def get_network_access_group( - cls, - tenant_id: str, - group_id: str, - actor_account_id: str, - ) -> dict[str, Any]: - """Fetch one reusable group scoped to its owning workspace.""" - - return cls._send_network_access_group_request( - "GET", - f"/tenants/{tenant_id}/network-access-groups/{group_id}", - params={"actor_account_id": actor_account_id}, - ) - - @classmethod - def update_network_access_group( - cls, - tenant_id: str, - group_id: str, - *, - name: str, - description: str, - allowed_cidrs: list[str], - expected_version: int, - actor_account_id: str, - ) -> dict[str, Any]: - """Replace a reusable group using optimistic concurrency control.""" - - return cls._send_network_access_group_request( - "PUT", - f"/tenants/{tenant_id}/network-access-groups/{group_id}", - payload_json={ - "name": name, - "description": description, - "allowed_cidrs": allowed_cidrs, - "expected_version": expected_version, - "actor_account_id": actor_account_id, - }, - ) - - @classmethod - def delete_network_access_group( - cls, - tenant_id: str, - group_id: str, - *, - expected_version: int, - actor_account_id: str, - ) -> dict[str, Any]: - """Delete a reusable group using optimistic concurrency control.""" - - return cls._send_network_access_group_request( - "DELETE", - f"/tenants/{tenant_id}/network-access-groups/{group_id}", - params={ - "expected_version": expected_version, - "actor_account_id": actor_account_id, - }, - ) - - @classmethod - def get_app_network_access_group( - cls, - tenant_id: str, - app_id: str, - actor_account_id: str, - ) -> dict[str, Any]: - """Fetch the reusable group currently assigned to one tenant-scoped app.""" - - return cls._send_network_access_group_request( - "GET", - f"/tenants/{tenant_id}/apps/{app_id}/network-access-group", - params={"actor_account_id": actor_account_id}, - ) - - @classmethod - def update_app_network_access_group( - cls, - tenant_id: str, - app_id: str, - *, - enabled: bool, - group_id: str | None, - access_points: list[str], - expected_version: int, - actor_account_id: str, - ) -> dict[str, Any]: - """Atomically replace one tenant-scoped App's complete network access configuration.""" - - return cls._send_network_access_group_request( - "PUT", - f"/tenants/{tenant_id}/apps/{app_id}/network-access-group", - payload_json={ - "enabled": enabled, - "group_id": group_id, - "access_points": access_points, - "expected_version": expected_version, - "actor_account_id": actor_account_id, - }, - ) - - @classmethod - def cleanup_app_network_access_group_binding(cls, tenant_id: str, app_id: str) -> dict[str, Any]: - """Remove an App binding through the internal App lifecycle endpoint. - - This must only be called by the post-commit App deletion task. Account - or workspace-member deletion does not own App bindings and must not use - this operation. - """ - - return cls._send_network_access_group_request( - "DELETE", - f"/tenants/{tenant_id}/apps/{app_id}/network-access-group-binding", - ) - - @classmethod - def _send_network_access_group_request( - cls, - method: Literal["GET", "POST", "PUT", "DELETE"], - endpoint: str, - *, - payload_json: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Call the SaaS group API while preserving its status for the Console boundary.""" - - try: - response = cls._send_network_access_group_http_request( - method, - endpoint, - payload_json=payload_json, - params=params, - ) - except httpx.RequestError as exc: - raise NetworkAccessGroupUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc - - if response.status_code not in (httpx.codes.OK, httpx.codes.CREATED): - reason = None - try: - error_payload = response.json() - if isinstance(error_payload, dict) and isinstance(error_payload.get("reason"), str): - reason = error_payload["reason"] - except (json.JSONDecodeError, TypeError, ValueError): - pass - raise NetworkAccessGroupUpstreamError(response.status_code, reason) - - try: - payload = response.json() - except (json.JSONDecodeError, TypeError, ValueError) as exc: - raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) from exc - - if not isinstance(payload, dict): - raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) - return payload - - @classmethod - @retry( - wait=wait_fixed(2), - stop=stop_before_delay(10), - retry=retry_if_exception_type(httpx.RequestError), - reraise=True, - ) - def _send_network_access_group_http_request( - cls, - method: Literal["GET", "POST", "PUT", "DELETE"], - endpoint: str, - *, - payload_json: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - ) -> httpx.Response: - headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} - network_access_base_url = dify_config.NETWORK_ACCESS_API_URL.strip() or cls.base_url - return _http_client.request( - method, - f"{network_access_base_url.rstrip('/')}{endpoint}", - json=payload_json, - params=params, - headers=headers, - follow_redirects=True, - ) - @classmethod def _send_quota_request( cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None diff --git a/api/services/network_access_group_gateway.py b/api/services/network_access_group_gateway.py new file mode 100644 index 00000000000000..af45b25e3fbb36 --- /dev/null +++ b/api/services/network_access_group_gateway.py @@ -0,0 +1,235 @@ +"""HTTP and Billing adapters used by the network access group service.""" + +import json +from typing import Any, Literal, override + +import httpx +from pydantic import ValidationError +from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed + +from core.helper.http_client_pooling import get_pooled_http_client +from enums import CloudPlan +from services.billing_service import BillingService, _BillingHTTPStatusError +from services.network_access_group_service import ( + NetworkAccessGroupControlPlane, + NetworkAccessGroupEntitlement, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupUpstreamError, +) + +_http_client: httpx.Client = get_pooled_http_client( + "network-access-group:default", + lambda: httpx.Client( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), + ), +) + + +class NetworkAccessGroupGateway(NetworkAccessGroupControlPlane): + def __init__( + self, + *, + base_url: str, + fallback_base_url: str, + secret_key: str, + http_client: httpx.Client = _http_client, + ) -> None: + self._base_url = base_url.strip() or fallback_base_url + self._secret_key = secret_key + self._http_client = http_client + + @override + def list_groups(self, workspace_id: str, actor_account_id: str) -> dict[str, Any]: + return self._send_request( + "GET", + f"/tenants/{workspace_id}/network-access-groups", + params={"actor_account_id": actor_account_id}, + ) + + @override + def create_group( + self, + workspace_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + actor_account_id: str, + ) -> dict[str, Any]: + return self._send_request( + "POST", + f"/tenants/{workspace_id}/network-access-groups", + payload_json={ + "name": name, + "description": description, + "allowed_cidrs": allowed_cidrs, + "actor_account_id": actor_account_id, + }, + ) + + @override + def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> dict[str, Any]: + return self._send_request( + "GET", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + params={"actor_account_id": actor_account_id}, + ) + + @override + def update_group( + self, + workspace_id: str, + group_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + return self._send_request( + "PUT", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + payload_json={ + "name": name, + "description": description, + "allowed_cidrs": allowed_cidrs, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @override + def delete_group( + self, + workspace_id: str, + group_id: str, + *, + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + return self._send_request( + "DELETE", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + params={ + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @override + def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> dict[str, Any]: + return self._send_request( + "GET", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group", + params={"actor_account_id": actor_account_id}, + ) + + @override + def update_app_binding( + self, + workspace_id: str, + app_id: str, + *, + enabled: bool, + group_id: str | None, + access_points: list[str], + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: + return self._send_request( + "PUT", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group", + payload_json={ + "enabled": enabled, + "group_id": group_id, + "access_points": access_points, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ) + + @override + def cleanup_app_binding(self, workspace_id: str, app_id: str) -> dict[str, Any]: + return self._send_request( + "DELETE", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group-binding", + ) + + def _send_request( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = self._send_http_request( + method, + endpoint, + payload_json=payload_json, + params=params, + ) + except httpx.RequestError as exc: + raise NetworkAccessGroupUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc + + if response.status_code not in (httpx.codes.OK, httpx.codes.CREATED): + reason = None + try: + error_payload = response.json() + if isinstance(error_payload, dict) and isinstance(error_payload.get("reason"), str): + reason = error_payload["reason"] + except (json.JSONDecodeError, TypeError, ValueError): + pass + raise NetworkAccessGroupUpstreamError(response.status_code, reason) + + try: + payload = response.json() + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) from exc + + if not isinstance(payload, dict): + raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) + return payload + + @retry( + wait=wait_fixed(2), + stop=stop_before_delay(10), + retry=retry_if_exception_type(httpx.RequestError), + reraise=True, + ) + def _send_http_request( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": self._secret_key} + return self._http_client.request( + method, + f"{self._base_url.rstrip('/')}{endpoint}", + json=payload_json, + params=params, + headers=headers, + follow_redirects=True, + ) + + +class BillingNetworkAccessGroupEntitlementGateway(NetworkAccessGroupEntitlement): + @override + def is_paid_plan(self, workspace_id: str) -> bool: + try: + billing_info = BillingService.get_info(workspace_id, exclude_vector_space=True) + except ( + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + _BillingHTTPStatusError, + ) as exc: + raise NetworkAccessGroupEntitlementUnavailableError from exc + return billing_info["subscription"]["plan"] in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM) diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index dcbe2e8908639c..48e28585afa0a3 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -1,23 +1,345 @@ -import logging -from typing import Any +"""Application service for workspace network access groups.""" -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError +import logging +from collections.abc import Sequence +from typing import Any, Literal, NamedTuple, Protocol -from extensions.ext_database import db -from models import App +from machinery.context import RequestContext logger = logging.getLogger(__name__) +NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"] + +_ACCESS_POINTS_BY_APP_MODE: dict[str, tuple[NetworkAccessPoint, ...]] = { + "workflow": ("webapp", "service_api", "mcp", "trigger"), + "advanced-chat": ("webapp", "service_api", "mcp"), + "chat": ("webapp", "service_api", "mcp"), + "completion": ("webapp", "service_api", "mcp"), + "agent-chat": ("webapp", "service_api", "mcp"), + "agent": ("webapp", "service_api"), +} + + +class NetworkAccessGroupError(Exception): + """Expected network access group use-case failure.""" + + +class NetworkAccessGroupUpstreamError(NetworkAccessGroupError): + """Failure returned by the network-access control plane.""" + + def __init__(self, status_code: int, reason: str | None = None) -> None: + self.status_code = status_code + self.reason = reason + super().__init__(f"network access group upstream returned HTTP {status_code}") + + +class NetworkAccessGroupAccessDeniedError(NetworkAccessGroupError): + """The actor is not a persisted owner or administrator of the workspace.""" + + +class NetworkAccessGroupEntitlementUnavailableError(NetworkAccessGroupError): + """The workspace plan could not be resolved.""" + + +class NetworkAccessGroupAppNotFoundError(NetworkAccessGroupError): + """The tenant-scoped App does not exist or is not manageable.""" + + +class NetworkAccessGroupUnsupportedAppModeError(NetworkAccessGroupError): + def __init__(self, app_mode: str) -> None: + self.app_mode = app_mode + super().__init__(f"network access control is not supported for app mode {app_mode!r}") + + +class NetworkAccessGroupUnsupportedAccessPointsError(NetworkAccessGroupError): + def __init__(self, access_points: Sequence[str]) -> None: + self.access_points = tuple(access_points) + super().__init__(f"unsupported app access points: {', '.join(self.access_points)}") + + +class NetworkAccessGroupAppQueryError(Exception): + """The local App read model could not be queried.""" + + +class NetworkAccessGroupAppRecord(NamedTuple): + id: str + mode: str + name: str + icon: str | None + icon_type: str | None + icon_background: str | None + + +class NetworkAccessGroupAppQuery(Protocol): + def get_manageable_app(self, *, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord | None: ... + + def list_apps( + self, + *, + workspace_id: str, + app_ids: Sequence[str], + ) -> Sequence[NetworkAccessGroupAppRecord]: ... + + +class WorkspaceMembershipRoleQuery(Protocol): + def get_role_for_account(self, *, workspace_id: str, account_id: str) -> str | None: ... + + +class NetworkAccessGroupEntitlement(Protocol): + def is_paid_plan(self, workspace_id: str) -> bool: ... + + +class NetworkAccessGroupControlPlane(Protocol): + def list_groups(self, workspace_id: str, actor_account_id: str) -> dict[str, Any]: ... + + def create_group( + self, + workspace_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + actor_account_id: str, + ) -> dict[str, Any]: ... + + def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> dict[str, Any]: ... + + def update_group( + self, + workspace_id: str, + group_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: ... + + def delete_group( + self, + workspace_id: str, + group_id: str, + *, + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: ... + + def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> dict[str, Any]: ... + + def update_app_binding( + self, + workspace_id: str, + app_id: str, + *, + enabled: bool, + group_id: str | None, + access_points: list[str], + expected_version: int, + actor_account_id: str, + ) -> dict[str, Any]: ... + + def cleanup_app_binding(self, workspace_id: str, app_id: str) -> dict[str, Any]: ... + class NetworkAccessGroupService: - """Dify-side orchestration for SaaS network access group responses.""" + def __init__( + self, + *, + control_plane: NetworkAccessGroupControlPlane, + apps: NetworkAccessGroupAppQuery, + memberships: WorkspaceMembershipRoleQuery, + entitlement: NetworkAccessGroupEntitlement, + ) -> None: + self._control_plane = control_plane + self._apps = apps + self._memberships = memberships + self._entitlement = entitlement + + def list_groups(self, context: RequestContext) -> dict[str, Any]: + self._ensure_workspace_admin_or_owner(context) + payload = self._control_plane.list_groups(context.active_workspace_id, context.account_id) + payload["entitled"] = self._effective_entitlement( + context.active_workspace_id, + payload.get("entitled"), + ) + return self._enrich_app_references(payload, context.active_workspace_id) + + def create_group( + self, + context: RequestContext, + *, + name: str, + description: str, + allowed_cidrs: list[str], + ) -> dict[str, Any]: + self._ensure_workspace_admin_or_owner(context) + payload = self._control_plane.create_group( + context.active_workspace_id, + name=name, + description=description, + allowed_cidrs=allowed_cidrs, + actor_account_id=context.account_id, + ) + return self._enrich_app_references(payload, context.active_workspace_id) + + def get_group(self, context: RequestContext, *, group_id: str) -> dict[str, Any]: + self._ensure_workspace_admin_or_owner(context) + payload = self._control_plane.get_group( + context.active_workspace_id, + group_id, + context.account_id, + ) + return self._enrich_app_references(payload, context.active_workspace_id) + + def update_group( + self, + context: RequestContext, + *, + group_id: str, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + ) -> dict[str, Any]: + self._ensure_workspace_admin_or_owner(context) + payload = self._control_plane.update_group( + context.active_workspace_id, + group_id, + name=name, + description=description, + allowed_cidrs=allowed_cidrs, + expected_version=expected_version, + actor_account_id=context.account_id, + ) + return self._enrich_app_references(payload, context.active_workspace_id) + + def delete_group( + self, + context: RequestContext, + *, + group_id: str, + expected_version: int, + ) -> dict[str, Any]: + self._ensure_workspace_admin_or_owner(context) + return self._control_plane.delete_group( + context.active_workspace_id, + group_id, + expected_version=expected_version, + actor_account_id=context.account_id, + ) + + def get_app_binding(self, context: RequestContext, *, app_id: str) -> dict[str, Any]: + app = self._get_manageable_app(context.active_workspace_id, app_id) + self._ensure_workspace_admin_or_owner(context) + available_access_points = self._available_access_points(app.mode) + payload = self._control_plane.get_app_binding( + context.active_workspace_id, + app.id, + context.account_id, + ) + self._normalize_app_binding_defaults(payload) + self._limit_binding_access_points(payload, available_access_points) + payload["entitled"] = self._effective_entitlement( + context.active_workspace_id, + payload.get("entitled"), + ) + payload["available_access_points"] = available_access_points + return payload + + def update_app_binding( + self, + context: RequestContext, + *, + app_id: str, + enabled: bool, + group_id: str | None, + access_points: list[NetworkAccessPoint], + expected_version: int, + ) -> dict[str, Any]: + app = self._get_manageable_app(context.active_workspace_id, app_id) + self._ensure_workspace_admin_or_owner(context) + available_access_points = self._available_access_points(app.mode) + self._validate_app_access_points(access_points, available_access_points) + payload = self._control_plane.update_app_binding( + context.active_workspace_id, + app.id, + enabled=enabled, + group_id=group_id, + access_points=list(access_points), + expected_version=expected_version, + actor_account_id=context.account_id, + ) + self._normalize_app_binding_defaults(payload) + self._limit_binding_access_points(payload, available_access_points) + payload["available_access_points"] = available_access_points + return payload + + def cleanup_app_binding(self, *, workspace_id: str, app_id: str) -> dict[str, Any]: + return self._control_plane.cleanup_app_binding(workspace_id, app_id) + + def _ensure_workspace_admin_or_owner(self, context: RequestContext) -> None: + role = self._memberships.get_role_for_account( + workspace_id=context.active_workspace_id, + account_id=context.account_id, + ) + if role not in {"owner", "admin"}: + raise NetworkAccessGroupAccessDeniedError + + def _effective_entitlement(self, workspace_id: str, upstream_entitled: object) -> bool: + return bool(upstream_entitled) and self._entitlement.is_paid_plan(workspace_id) + + def _get_manageable_app(self, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord: + app = self._apps.get_manageable_app(workspace_id=workspace_id, app_id=app_id) + if app is None: + raise NetworkAccessGroupAppNotFoundError + return app + + @staticmethod + def _available_access_points(app_mode: str) -> list[NetworkAccessPoint]: + available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode) + if available is None: + raise NetworkAccessGroupUnsupportedAppModeError(app_mode) + return list(available) + + @staticmethod + def _validate_app_access_points( + requested: Sequence[NetworkAccessPoint], + available: Sequence[NetworkAccessPoint], + ) -> None: + unsupported = sorted(set(requested).difference(available)) + if unsupported: + raise NetworkAccessGroupUnsupportedAccessPointsError(unsupported) @staticmethod - def enrich_app_references( + def _normalize_app_binding_defaults(payload: dict[str, Any]) -> None: + """Materialize ProtoJSON defaults so the Console response stays stable.""" + + payload.setdefault("binding", None) + binding = payload.get("binding") + if not isinstance(binding, dict): + return + binding.setdefault("enabled", False) + if "access_points" not in binding and "accessPoints" not in binding: + binding["access_points"] = [] + + @staticmethod + def _limit_binding_access_points( payload: dict[str, Any], - tenant_id: str, - ) -> dict[str, Any]: + available: Sequence[NetworkAccessPoint], + ) -> None: + """Hide stale scopes which are not real access points for the current App mode.""" + + binding = payload.get("binding") + if not isinstance(binding, dict): + return + raw_access_points = binding.get("access_points", binding.get("accessPoints", [])) + if not isinstance(raw_access_points, list): + return + binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available] + binding.pop("accessPoints", None) + + def _enrich_app_references(self, payload: dict[str, Any], workspace_id: str) -> dict[str, Any]: """Attach tenant-scoped App display metadata to one or more group payloads.""" raw_groups: list[object] = [] @@ -56,27 +378,25 @@ def enrich_app_references( return payload try: - app_models = db.session.scalars( - select(App).where( - App.tenant_id == tenant_id, - App.id.in_(app_ids), - App.status == "normal", - ) - ).all() - except SQLAlchemyError: - logger.exception("Failed to enrich network access Policy App references", extra={"tenant_id": tenant_id}) + app_records = self._apps.list_apps(workspace_id=workspace_id, app_ids=tuple(app_ids)) + except NetworkAccessGroupAppQueryError: + logger.exception( + "Failed to enrich network access group App references", + extra={"workspace_id": workspace_id}, + ) for group in groups: group["apps"] = [] return payload + apps_by_id = { - str(app.id): { - "id": str(app.id), + app.id: { + "id": app.id, "name": app.name, "icon": app.icon, - "icon_type": app.icon_type.value if app.icon_type is not None else None, + "icon_type": app.icon_type, "icon_background": app.icon_background, } - for app in app_models + for app in app_records } for group in groups: group_app_ids = group["app_ids"] if isinstance(group["app_ids"], list) else [] diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 52212131124fda..40b6f70b48152d 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -52,7 +52,6 @@ ) from repositories.factory import DifyAPIRepositoryFactory from services.api_token_service import ApiTokenCache -from services.billing_service import BillingService logger = logging.getLogger(__name__) @@ -76,7 +75,14 @@ def cleanup_app_network_access_group_binding_task(self, tenant_id: str, app_id: if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return try: - BillingService.cleanup_app_network_access_group_binding(tenant_id, app_id) + # Import lazily because AppService imports this task module while the + # application-service composition root imports AppService dependencies. + from extensions.ext_application_services import application_services + + application_services().network_access_groups.cleanup_app_binding( + workspace_id=tenant_id, + app_id=app_id, + ) except Exception as e: countdown = _network_access_binding_cleanup_retry_delay(self.request.retries) logger.exception( diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 62b6ab93a2dcb9..1b50ad3bf15a56 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -1,16 +1,17 @@ -from collections.abc import Iterator +from collections.abc import Callable, Generator +from contextlib import contextmanager from datetime import UTC, datetime from inspect import unwrap from types import SimpleNamespace -from typing import cast from unittest.mock import MagicMock, patch from uuid import UUID import pytest +from flask import Flask from pydantic import ValidationError -from sqlalchemy.exc import SQLAlchemyError from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable +from controllers.console.app.error import AppNotFoundError from controllers.console.workspace.network_access_group import ( AppNetworkAccessGroupApi, AppNetworkAccessGroupUpdatePayload, @@ -19,14 +20,19 @@ NetworkAccessGroupCreatePayload, NetworkAccessGroupDeleteQuery, NetworkAccessGroupUpdatePayload, - _available_access_points, - _effective_entitlement, - _limit_binding_access_points, + _translate_service_error, _translate_upstream_error, ) -from models import App, AppMode, TenantAccountRole -from services.billing_service import BillingService, NetworkAccessGroupUpstreamError -from services.network_access_group_service import NetworkAccessGroupService +from machinery.context import RequestContext +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupError, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, +) TENANT_ID = "11111111-1111-4111-8111-111111111111" ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" @@ -35,15 +41,20 @@ BINDING_ID = "55555555-5555-4555-8555-555555555555" -def _current_user(role: TenantAccountRole = TenantAccountRole.OWNER) -> SimpleNamespace: - return SimpleNamespace(id=ACCOUNT_ID, current_role=role) +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-id", + trace_id=None, + account_id=ACCOUNT_ID, + active_workspace_id=TENANT_ID, + ) -@pytest.fixture(autouse=True) -def paid_entitlement() -> Iterator[None]: +@contextmanager +def _application_services(service: MagicMock) -> Generator[None]: with patch( - "controllers.console.workspace.network_access_group._effective_entitlement", - side_effect=lambda _tenant_id, upstream: bool(upstream), + "controllers.console.workspace.network_access_group.application_services", + return_value=SimpleNamespace(network_access_groups=service), ): yield @@ -58,7 +69,7 @@ def _group_payload(*, app_ids: list[str] | None = None) -> dict[str, object]: "allowedCidrs": ["203.0.113.7/32"], "usedByCount": len(app_ids), "usedByAppIds": app_ids, - # ProtoJSON represents int64 values as strings and timestamps as RFC 3339. + "apps": [], "version": "2", "updatedByAccountId": ACCOUNT_ID, "createdAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(), @@ -86,211 +97,135 @@ def _binding_payload( } -def test_list_authorizes_and_forwards_tenant_and_actor() -> None: +def test_list_forwards_request_context_and_serializes_response() -> None: + service = MagicMock() + service.list_groups.return_value = { + "tenantId": TENANT_ID, + "entitled": True, + "groups": [_group_payload()], + } api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.get) - current_user = _current_user() - upstream_payload = {"tenantId": TENANT_ID, "entitled": True, "groups": [_group_payload()]} - with ( - patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload) as list_groups, - ): - result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) + with _application_services(service): + result = unwrap(api.get)(api, request_context=_request_context()) - list_groups.assert_called_once_with(TENANT_ID, ACCOUNT_ID) + service.list_groups.assert_called_once_with(_request_context()) assert result["tenant_id"] == TENANT_ID - assert result["groups"][0]["name"] == "Office network" assert result["groups"][0]["version"] == 2 - assert result["groups"][0]["used_by_count"] == 0 - assert result["groups"][0]["app_ids"] == [] - assert result["groups"][0]["apps"] == [] assert result["groups"][0]["updated_at"] == "2026-08-21T00:00:00Z" -def test_create_injects_actor_and_returns_created_contract() -> None: - api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.post) - current_user = _current_user() +def test_create_forwards_payload_and_returns_201() -> None: + service = MagicMock() + service.create_group.return_value = {"group": _group_payload()} request_payload = NetworkAccessGroupCreatePayload( name="Office network", description="Reusable office egress addresses", allowed_cidrs=["203.0.113.7/32"], ) + api = CurrentWorkspaceNetworkAccessGroupsApi() - with ( - patch.object( - BillingService, - "create_network_access_group", - return_value={"group": _group_payload()}, - ) as create_group, - ): - body, status = method( + with _application_services(service): + body, status = unwrap(api.post)( api, req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=current_user, + request_context=_request_context(), ) - create_group.assert_called_once_with( - TENANT_ID, + service.create_group.assert_called_once_with( + _request_context(), name="Office network", description="Reusable office egress addresses", allowed_cidrs=["203.0.113.7/32"], - actor_account_id=ACCOUNT_ID, ) assert status == 201 assert body["group"]["id"] == GROUP_ID -def test_update_group_forwards_path_id_and_expected_version() -> None: - api = CurrentWorkspaceNetworkAccessGroupApi() - method = unwrap(api.put) - current_user = _current_user() - request_payload = NetworkAccessGroupUpdatePayload( +def test_group_detail_update_and_delete_forward_path_and_query_values() -> None: + service = MagicMock() + service.get_group.return_value = {"group": _group_payload()} + service.update_group.return_value = {"group": _group_payload()} + service.delete_group.return_value = {"deleted": True} + update_payload = NetworkAccessGroupUpdatePayload( name="Office network", description="Updated", allowed_cidrs=["203.0.113.0/24"], expected_version=1, ) + api = CurrentWorkspaceNetworkAccessGroupApi() - with ( - patch.object( - BillingService, - "update_network_access_group", - return_value={"group": _group_payload()}, - ) as update_group, - ): - result = method( + with _application_services(service): + get_result = unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + update_result = unwrap(api.put)( api, - req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=current_user, + req_data=update_payload, + request_context=_request_context(), group_id=UUID(GROUP_ID), ) - - update_group.assert_called_once_with( - TENANT_ID, - GROUP_ID, - name="Office network", - description="Updated", - allowed_cidrs=["203.0.113.0/24"], - expected_version=1, - actor_account_id=ACCOUNT_ID, - ) - assert result["group"]["version"] == 2 - - -def test_delete_group_reads_expected_version_from_query_and_injects_actor() -> None: - api = CurrentWorkspaceNetworkAccessGroupApi() - method = unwrap(api.delete) - current_user = _current_user() - - with ( - patch.object( - BillingService, - "delete_network_access_group", - return_value={"deleted": True}, - ) as delete_group, - ): - result = method( + delete_result = unwrap(api.delete)( api, req_data=NetworkAccessGroupDeleteQuery(expected_version=2), - current_tenant_id=TENANT_ID, - current_user=current_user, + request_context=_request_context(), group_id=UUID(GROUP_ID), ) - delete_group.assert_called_once_with( - TENANT_ID, - GROUP_ID, - expected_version=2, - actor_account_id=ACCOUNT_ID, + service.get_group.assert_called_once_with(_request_context(), group_id=GROUP_ID) + service.update_group.assert_called_once_with( + _request_context(), + group_id=GROUP_ID, + name="Office network", + description="Updated", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, ) - assert result == {"deleted": True} - - -def test_app_get_forwards_tenant_scoped_app_and_supports_unbound_response() -> None: - api = AppNetworkAccessGroupApi() - method = unwrap(api.get) - current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) - upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True} + service.delete_group.assert_called_once_with(_request_context(), group_id=GROUP_ID, expected_version=2) + assert get_result["group"]["id"] == GROUP_ID + assert update_result["group"]["version"] == 2 + assert delete_result == {"deleted": True} - with ( - patch.object( - BillingService, - "get_app_network_access_group", - return_value=upstream_payload, - ) as get_binding, - ): - result = method( - api, - current_tenant_id=TENANT_ID, - current_user=current_user, - app_model=app_model, - ) - get_binding.assert_called_once_with(TENANT_ID, APP_ID, ACCOUNT_ID) - assert result["binding"] is None - assert result["available_access_points"] == ["webapp", "service_api", "mcp"] - - -@pytest.mark.parametrize("enabled", [True, False]) -def test_app_put_atomically_saves_enabled_group_and_access_points(enabled: bool) -> None: - api = AppNetworkAccessGroupApi() - method = unwrap(api.put) - current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) +def test_app_get_and_put_forward_app_id_without_orm_models() -> None: + service = MagicMock() + service.get_app_binding.return_value = { + "tenantId": TENANT_ID, + "appId": APP_ID, + "entitled": True, + "available_access_points": ["webapp", "service_api", "mcp"], + "binding": None, + } + service.update_app_binding.return_value = { + "binding": _binding_payload(), + "available_access_points": ["webapp", "service_api", "mcp"], + } request_payload = AppNetworkAccessGroupUpdatePayload( - enabled=enabled, + enabled=True, group_id=GROUP_ID, access_points=["webapp", "service_api"], expected_version=2, ) + api = AppNetworkAccessGroupApi() - with ( - patch.object( - BillingService, - "update_app_network_access_group", - return_value={"binding": _binding_payload(enabled=enabled)}, - ) as update_binding, - ): - result = method( + with _application_services(service): + get_result = unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + put_result = unwrap(api.put)( api, req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=current_user, - app_model=app_model, + request_context=_request_context(), + app_id=UUID(APP_ID), ) - update_binding.assert_called_once_with( - TENANT_ID, - APP_ID, - enabled=enabled, + service.get_app_binding.assert_called_once_with(_request_context(), app_id=APP_ID) + service.update_app_binding.assert_called_once_with( + _request_context(), + app_id=APP_ID, + enabled=True, group_id=GROUP_ID, access_points=["webapp", "service_api"], expected_version=2, - actor_account_id=ACCOUNT_ID, ) - assert result["binding"]["enabled"] is enabled - assert result["binding"]["group_id"] == GROUP_ID - assert result["binding"]["access_points"] == ["webapp", "service_api"] - assert result["binding"]["version"] == 3 - assert result["available_access_points"] == ["webapp", "service_api", "mcp"] - - -def test_list_rejects_non_privileged_workspace_member_before_upstream_call() -> None: - api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.get) - current_user = _current_user(TenantAccountRole.NORMAL) - - with ( - patch.object(BillingService, "list_network_access_groups") as list_groups, - pytest.raises(Forbidden), - ): - method(api, current_tenant_id=TENANT_ID, current_user=current_user) - - list_groups.assert_not_called() + assert get_result["binding"] is None + assert put_result["binding"]["version"] == 3 @pytest.mark.parametrize( @@ -325,124 +260,88 @@ def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, ex def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None: - error = _translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY")) - assert isinstance(error, ServiceUnavailable) - - -def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None: - api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.get) - current_user = _current_user() - - with ( - patch.object( - BillingService, - "list_network_access_groups", - return_value={"tenant_id": TENANT_ID, "entitled": True, "groups": [{"allowed_cidrs": []}]}, - ), - pytest.raises(BadGateway), - ): - method(api, current_tenant_id=TENANT_ID, current_user=current_user) - - -def test_response_contract_accepts_protojson_omitted_optional_group_fields() -> None: - api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.get) - current_user = _current_user() - group = _group_payload() - group.pop("description") - group.pop("usedByAppIds") - group.pop("usedByCount") + assert isinstance( + _translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY")), + ServiceUnavailable, + ) - with ( - patch.object( - BillingService, - "list_network_access_groups", - return_value={"tenantId": TENANT_ID, "entitled": True, "groups": [group]}, - ), - ): - result = method(api, current_tenant_id=TENANT_ID, current_user=current_user) - assert result["groups"][0]["description"] == "" - assert result["groups"][0]["allowed_cidrs"] == ["203.0.113.7/32"] - assert result["groups"][0]["app_ids"] == [] - assert result["groups"][0]["used_by_count"] == 0 +@pytest.mark.parametrize( + ("service_error", "expected_exception", "expected_message"), + [ + (NetworkAccessGroupAccessDeniedError(), Forbidden, "owners and administrators"), + (NetworkAccessGroupEntitlementUnavailableError(), ServiceUnavailable, "temporarily unavailable"), + (NetworkAccessGroupAppNotFoundError(), AppNotFoundError, "not found"), + (NetworkAccessGroupUnsupportedAppModeError("channel"), BadRequest, "channel"), + (NetworkAccessGroupUnsupportedAccessPointsError(["trigger"]), BadRequest, "trigger"), + ], +) +def test_application_error_mapping( + service_error: NetworkAccessGroupError, + expected_exception: type[HTTPException], + expected_message: str, +) -> None: + error = _translate_service_error(service_error) + assert isinstance(error, expected_exception) + assert error.description is not None + assert expected_message in error.description.lower() -def test_binding_response_materializes_protojson_omitted_disabled_defaults() -> None: - api = AppNetworkAccessGroupApi() - method = unwrap(api.put) - current_user = _current_user() - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) - request_payload = AppNetworkAccessGroupUpdatePayload( - enabled=False, - group_id=None, - access_points=[], - expected_version=2, - ) - binding = _binding_payload(enabled=False, group_id=None, access_points=[]) - binding.pop("groupId") - binding.pop("enabled") - binding.pop("accessPoints") +@pytest.mark.parametrize( + "mutation", + [ + CurrentWorkspaceNetworkAccessGroupsApi.post, + CurrentWorkspaceNetworkAccessGroupApi.put, + CurrentWorkspaceNetworkAccessGroupApi.delete, + AppNetworkAccessGroupApi.put, + ], +) +def test_mutation_paid_plan_admission_precedes_payload_validation(mutation: Callable[..., object]) -> None: + app = Flask(__name__) + paid_plan_wrapper = unwrap(mutation, stop=lambda candidate: candidate is not mutation) with ( - patch.object( - BillingService, - "update_app_network_access_group", - return_value={"binding": binding}, + app.test_request_context(json={"name": "", "description": "", "allowed_cidrs": []}), + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(object(), TENANT_ID), ), + patch( + "controllers.console.wraps.is_cloud_edition_billing_paid_plan", + return_value=False, + ) as is_paid_plan, + pytest.raises(HTTPException) as exc_info, ): - result = method( - api, - req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=current_user, - app_model=app_model, - ) + paid_plan_wrapper(object()) - assert result["binding"]["group_id"] is None - assert result["binding"]["enabled"] is False - assert result["binding"]["access_points"] == [] + assert exc_info.value.code == 403 + is_paid_plan.assert_called_once_with(TENANT_ID) -def test_binding_response_accepts_policy_id_alias() -> None: - api = AppNetworkAccessGroupApi() - method = unwrap(api.put) - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT) - request_payload = AppNetworkAccessGroupUpdatePayload( - enabled=True, - group_id=GROUP_ID, - access_points=["webapp"], - expected_version=2, - ) - binding = _binding_payload(access_points=["webapp"]) - binding["policyId"] = binding.pop("groupId") +def test_controller_maps_service_error_before_serialization() -> None: + service = MagicMock() + service.list_groups.side_effect = NetworkAccessGroupAccessDeniedError - with patch.object(BillingService, "update_app_network_access_group", return_value={"binding": binding}): - result = method( - api, - req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=_current_user(), - app_model=app_model, + with _application_services(service), pytest.raises(Forbidden, match="owners and administrators"): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), ) - assert result["binding"]["group_id"] == GROUP_ID - -def test_binding_response_hides_scopes_not_available_for_current_app_mode() -> None: - payload = { - "binding": _binding_payload( - enabled=True, - group_id=GROUP_ID, - access_points=["webapp", "service_api", "mcp", "trigger"], - ) +def test_invalid_service_response_is_bad_gateway() -> None: + service = MagicMock() + service.list_groups.return_value = { + "tenant_id": TENANT_ID, + "entitled": True, + "groups": [{"allowed_cidrs": list[str]()}], } - _limit_binding_access_points(payload, ["webapp", "service_api"]) - - assert payload["binding"]["access_points"] == ["webapp", "service_api"] - assert "accessPoints" not in payload["binding"] + with _application_services(service), pytest.raises(BadGateway): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), + ) @pytest.mark.parametrize( @@ -458,67 +357,6 @@ def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, objec NetworkAccessGroupCreatePayload.model_validate(payload) -def test_binding_payload_rejects_invalid_group_id_and_version() -> None: - with pytest.raises(ValidationError): - AppNetworkAccessGroupUpdatePayload.model_validate( - { - "enabled": True, - "group_id": "not-a-uuid", - "access_points": ["webapp"], - "expected_version": -1, - } - ) - - -@pytest.mark.parametrize( - ("app_mode", "expected"), - [ - (AppMode.WORKFLOW, ["webapp", "service_api", "mcp", "trigger"]), - (AppMode.ADVANCED_CHAT, ["webapp", "service_api", "mcp"]), - (AppMode.CHAT, ["webapp", "service_api", "mcp"]), - (AppMode.COMPLETION, ["webapp", "service_api", "mcp"]), - (AppMode.AGENT_CHAT, ["webapp", "service_api", "mcp"]), - (AppMode.AGENT, ["webapp", "service_api"]), - ], -) -def test_available_access_points_are_derived_from_app_mode(app_mode: AppMode, expected: list[str]) -> None: - app_model = SimpleNamespace(mode=app_mode) - - assert _available_access_points(cast(App, app_model)) == expected - - -@pytest.mark.parametrize("app_mode", [AppMode.CHANNEL, AppMode.RAG_PIPELINE]) -def test_unsupported_app_modes_are_rejected(app_mode: AppMode) -> None: - with pytest.raises(BadRequest, match="not supported"): - _available_access_points(cast(App, SimpleNamespace(mode=app_mode))) - - -def test_app_put_rejects_access_point_not_available_for_mode_before_upstream_call() -> None: - api = AppNetworkAccessGroupApi() - method = unwrap(api.put) - app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.AGENT) - request_payload = AppNetworkAccessGroupUpdatePayload( - enabled=True, - group_id=GROUP_ID, - access_points=["webapp", "mcp"], - expected_version=1, - ) - - with ( - patch.object(BillingService, "update_app_network_access_group") as update_binding, - pytest.raises(BadRequest, match="mcp"), - ): - method( - api, - req_data=request_payload, - current_tenant_id=TENANT_ID, - current_user=_current_user(), - app_model=app_model, - ) - - update_binding.assert_not_called() - - @pytest.mark.parametrize( "payload", [ @@ -550,83 +388,3 @@ def test_app_config_accepts_policy_id_alias_and_disabled_draft() -> None: assert str(payload.group_id) == GROUP_ID assert payload.access_points == ["webapp", "service_api"] - - -def test_group_responses_are_enriched_with_tenant_scoped_app_metadata() -> None: - app_id_2 = "66666666-6666-4666-8666-666666666666" - payload = { - "group": _group_payload(app_ids=[APP_ID, app_id_2]), - } - payload["group"].pop("usedByCount") - app = SimpleNamespace( - id=APP_ID, - name="Customer support", - icon="robot", - icon_type=SimpleNamespace(value="emoji"), - icon_background="#FFFFFF", - ) - db_mock = MagicMock() - db_mock.session.scalars.return_value.all.return_value = [app] - - with patch("services.network_access_group_service.db", db_mock): - result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID) - - assert result["group"]["apps"] == [ - { - "id": APP_ID, - "name": "Customer support", - "icon": "robot", - "icon_type": "emoji", - "icon_background": "#FFFFFF", - } - ] - assert result["group"]["app_ids"] == [APP_ID, app_id_2] - assert result["group"]["used_by_count"] == 2 - db_mock.session.scalars.assert_called_once() - - -def test_group_app_enrichment_degrades_to_ids_when_core_database_is_unavailable() -> None: - payload = {"group": _group_payload(app_ids=[APP_ID])} - db_mock = MagicMock() - db_mock.session.scalars.side_effect = SQLAlchemyError("database unavailable") - - with patch("services.network_access_group_service.db", db_mock): - result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID) - - assert result["group"]["app_ids"] == [APP_ID] - assert result["group"]["used_by_count"] == 1 - assert result["group"]["apps"] == [] - - -def test_sandbox_read_returns_not_entitled_for_upgrade_state() -> None: - api = CurrentWorkspaceNetworkAccessGroupsApi() - method = unwrap(api.get) - upstream_payload: dict[str, object] = { - "tenantId": TENANT_ID, - "entitled": True, - "groups": list[object](), - } - - with ( - patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload), - patch("controllers.console.workspace.network_access_group._effective_entitlement", return_value=False), - ): - result = method(api, current_tenant_id=TENANT_ID, current_user=_current_user()) - - assert result["entitled"] is False - - -@pytest.mark.parametrize( - ("upstream_entitled", "paid_plan", "expected"), - [(True, True, True), (False, True, False), (True, False, False)], -) -def test_effective_entitlement_requires_rollout_and_paid_plan( - upstream_entitled: bool, - paid_plan: bool, - expected: bool, -) -> None: - with patch( - "controllers.console.workspace.network_access_group.is_cloud_edition_billing_paid_plan", - return_value=paid_plan, - ): - assert _effective_entitlement(TENANT_ID, upstream_entitled) is expected diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index df115b908bf0f7..11ce08b16344c3 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -34,6 +34,7 @@ from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository from repositories.upload_file_delivery_repository import UploadFileDeliveryQueryRepository @@ -76,6 +77,11 @@ from services.human_input_file_upload_service import HumanInputFileUploadService from services.init_validation_service import InvalidInitializationPasswordError from services.message_file_preview_service import MessageFilePreviewService +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import NetworkAccessGroupService from services.partner_tenant_binding_service import PartnerTenantBindingService from services.plugin_file_upload_gateway import ToolFilePluginUploadGateway from services.plugin_file_upload_service import PluginFileUploadService @@ -479,6 +485,24 @@ def test_build_application_services_wires_billing_service( sync_partner_tenants_bindings.assert_called_once_with("account-1", "partner-key", "click-1") +def test_build_application_services_wires_network_access_group_boundary( + sqlite_session_factory: sessionmaker[Session], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.CLOUD, + initialization_password="", + redis=MagicMock(spec=RedisClientWrapper), + ) + + network_access_groups = services.network_access_groups + assert isinstance(network_access_groups, NetworkAccessGroupService) + assert isinstance(network_access_groups._control_plane, NetworkAccessGroupGateway) + assert isinstance(network_access_groups._apps, SQLAlchemyNetworkAccessGroupAppRepository) + assert network_access_groups._apps._session_factory is sqlite_session_factory + assert isinstance(network_access_groups._entitlement, BillingNetworkAccessGroupEntitlementGateway) + + def test_build_application_services_wires_compliance_downloads( sqlite_session_factory: sessionmaker[Session], ) -> None: diff --git a/api/tests/unit_tests/repositories/test_network_access_group_repository.py b/api/tests/unit_tests/repositories/test_network_access_group_repository.py new file mode 100644 index 00000000000000..49ce7a0fa7c087 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_network_access_group_repository.py @@ -0,0 +1,169 @@ +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from models.agent import Agent, AgentScope, AgentSource, AgentStatus +from models.model import App, AppMode, IconType +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository +from services.network_access_group_service import NetworkAccessGroupAppQueryError, NetworkAccessGroupAppRecord + +_WORKSPACE_ID = "11111111-1111-1111-1111-111111111111" +_OTHER_WORKSPACE_ID = "22222222-2222-2222-2222-222222222222" +_APP_ID = "33333333-3333-3333-3333-333333333333" +_OTHER_APP_ID = "44444444-4444-4444-4444-444444444444" +_DISABLED_APP_ID = "55555555-5555-5555-5555-555555555555" +_UNREQUESTED_APP_ID = "66666666-6666-6666-6666-666666666666" + + +def _app( + app_id: str, + *, + workspace_id: str = _WORKSPACE_ID, + mode: AppMode = AppMode.WORKFLOW, +) -> App: + return App( + id=app_id, + tenant_id=workspace_id, + name=f"App {app_id}", + description="", + mode=mode, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + ) + + +def _repository(session_factory: sessionmaker[Session]) -> SQLAlchemyNetworkAccessGroupAppRepository: + return SQLAlchemyNetworkAccessGroupAppRepository(session_factory=session_factory) + + +def test_get_manageable_app_returns_detached_projection_and_enforces_workspace_and_status_scope( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + session.add_all( + [ + _app(_APP_ID), + _app(_OTHER_APP_ID, workspace_id=_OTHER_WORKSPACE_ID), + _app(_DISABLED_APP_ID), + ] + ) + session.commit() + session.execute( + text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), + {"app_id": _DISABLED_APP_ID}, + ) + session.commit() + + repository = _repository(sqlite_session_factory) + + result = repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_APP_ID) + + assert result == NetworkAccessGroupAppRecord( + id=_APP_ID, + mode=AppMode.WORKFLOW.value, + name=f"App {_APP_ID}", + icon="robot", + icon_type=IconType.EMOJI.value, + icon_background="#ffffff", + ) + assert repository.get_manageable_app(workspace_id=_OTHER_WORKSPACE_ID, app_id=_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_OTHER_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_DISABLED_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_UNREQUESTED_APP_ID) is None + + +def test_list_apps_returns_only_normal_apps_in_the_requested_workspace( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + session.add_all( + [ + _app(_APP_ID), + _app(_OTHER_APP_ID, workspace_id=_OTHER_WORKSPACE_ID), + _app(_DISABLED_APP_ID), + _app(_UNREQUESTED_APP_ID), + ] + ) + session.commit() + session.execute( + text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), + {"app_id": _DISABLED_APP_ID}, + ) + session.commit() + + result = _repository(sqlite_session_factory).list_apps( + workspace_id=_WORKSPACE_ID, + app_ids=[_APP_ID, _OTHER_APP_ID, _DISABLED_APP_ID], + ) + + assert result == ( + NetworkAccessGroupAppRecord( + id=_APP_ID, + mode=AppMode.WORKFLOW.value, + name=f"App {_APP_ID}", + icon="robot", + icon_type=IconType.EMOJI.value, + icon_background="#ffffff", + ), + ) + + +@pytest.mark.parametrize( + ("scope", "source", "status", "expected_visible"), + [ + (AgentScope.WORKFLOW_ONLY, AgentSource.WORKFLOW, AgentStatus.ACTIVE, False), + (AgentScope.WORKFLOW_ONLY, AgentSource.WORKFLOW, AgentStatus.ARCHIVED, False), + (AgentScope.ROSTER, AgentSource.AGENT_APP, AgentStatus.ACTIVE, True), + ], +) +def test_get_manageable_app_applies_agent_scope_and_lifecycle_visibility( + sqlite_session_factory: sessionmaker[Session], + scope: AgentScope, + source: AgentSource, + status: AgentStatus, + expected_visible: bool, +) -> None: + app = _app(_APP_ID, mode=AppMode.AGENT) + with sqlite_session_factory() as session: + session.add_all( + [ + app, + Agent( + id="77777777-7777-7777-7777-777777777777", + tenant_id=_WORKSPACE_ID, + name="Bound agent", + description="", + role="", + scope=scope, + source=source, + status=status, + app_id=app.id if scope == AgentScope.ROSTER else "88888888-8888-8888-8888-888888888888", + backing_app_id=app.id if scope == AgentScope.WORKFLOW_ONLY else None, + workflow_id=("99999999-9999-9999-9999-999999999999" if scope == AgentScope.WORKFLOW_ONLY else None), + workflow_node_id="agent-node" if scope == AgentScope.WORKFLOW_ONLY else None, + ), + ] + ) + session.commit() + + result = _repository(sqlite_session_factory).get_manageable_app( + workspace_id=_WORKSPACE_ID, + app_id=_APP_ID, + ) + + assert (result is not None) is expected_visible + + +def test_list_apps_maps_database_failures_to_query_error() -> None: + session_factory = MagicMock() + session = session_factory.return_value.__enter__.return_value + session.scalars.side_effect = SQLAlchemyError("database unavailable") + + with pytest.raises(NetworkAccessGroupAppQueryError): + _repository(session_factory).list_apps(workspace_id=_WORKSPACE_ID, app_ids=[_APP_ID]) diff --git a/api/tests/unit_tests/repositories/test_workspace_query_repository.py b/api/tests/unit_tests/repositories/test_workspace_query_repository.py new file mode 100644 index 00000000000000..6ab368049af678 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_workspace_query_repository.py @@ -0,0 +1,58 @@ +from sqlalchemy.orm import Session, sessionmaker + +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from repositories.workspace_query_repository import WorkspaceQueryRepository + +_WORKSPACE_ID = "11111111-1111-1111-1111-111111111111" +_OTHER_WORKSPACE_ID = "22222222-2222-2222-2222-222222222222" +_OWNER_ID = "33333333-3333-3333-3333-333333333333" +_ADMIN_ID = "44444444-4444-4444-4444-444444444444" +_OTHER_WORKSPACE_ACCOUNT_ID = "55555555-5555-5555-5555-555555555555" + + +def _account(account_id: str) -> Account: + account = Account(name=f"Account {account_id}", email=f"{account_id}@example.com") + account.id = account_id + return account + + +def test_get_role_for_account_is_scoped_by_workspace_and_account( + sqlite_session_factory: sessionmaker[Session], +) -> None: + workspace = Tenant(name="Workspace") + workspace.id = _WORKSPACE_ID + other_workspace = Tenant(name="Other workspace") + other_workspace.id = _OTHER_WORKSPACE_ID + with sqlite_session_factory() as session: + session.add_all( + [ + workspace, + other_workspace, + _account(_OWNER_ID), + _account(_ADMIN_ID), + _account(_OTHER_WORKSPACE_ACCOUNT_ID), + TenantAccountJoin( + tenant_id=_WORKSPACE_ID, + account_id=_OWNER_ID, + role=TenantAccountRole.OWNER, + ), + TenantAccountJoin( + tenant_id=_WORKSPACE_ID, + account_id=_ADMIN_ID, + role=TenantAccountRole.ADMIN, + ), + TenantAccountJoin( + tenant_id=_OTHER_WORKSPACE_ID, + account_id=_OTHER_WORKSPACE_ACCOUNT_ID, + role=TenantAccountRole.OWNER, + ), + ] + ) + session.commit() + + repository = WorkspaceQueryRepository(sqlite_session_factory) + + assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_OWNER_ID) == "owner" + assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_ADMIN_ID) == "admin" + assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id="missing-account") is None + assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_OTHER_WORKSPACE_ACCOUNT_ID) is None diff --git a/api/tests/unit_tests/services/test_network_access_group_billing_service.py b/api/tests/unit_tests/services/test_network_access_group_gateway.py similarity index 54% rename from api/tests/unit_tests/services/test_network_access_group_billing_service.py rename to api/tests/unit_tests/services/test_network_access_group_gateway.py index c034f21904df60..9036672893812c 100644 --- a/api/tests/unit_tests/services/test_network_access_group_billing_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_gateway.py @@ -1,10 +1,18 @@ -from collections.abc import Callable, Iterator from unittest.mock import MagicMock, call, patch import httpx import pytest -from services.billing_service import BillingService, NetworkAccessGroupUpstreamError +from enums import CloudPlan +from services.billing_service import _BillingHTTPStatusError +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import ( + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupUpstreamError, +) TENANT_ID = "11111111-1111-4111-8111-111111111111" ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" @@ -13,30 +21,30 @@ HEADERS = {"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"} -@pytest.fixture -def billing_config(config_overrides: Callable[..., None]) -> Iterator[None]: - config_overrides(NETWORK_ACCESS_API_URL="") - with ( - patch.object(BillingService, "base_url", "https://billing.internal/v1"), - patch.object(BillingService, "secret_key", "test-secret"), - ): - yield +def _gateway(*, base_url: str = "https://network-access.internal/v1/") -> tuple[NetworkAccessGroupGateway, MagicMock]: + http_client = MagicMock(spec=httpx.Client) + return ( + NetworkAccessGroupGateway( + base_url=base_url, + fallback_base_url="https://billing.internal/v1", + secret_key="test-secret", + http_client=http_client, + ), + http_client, + ) -@pytest.mark.usefixtures("billing_config") -def test_network_access_requests_use_independent_api_url_when_configured( - config_overrides: Callable[..., None], -) -> None: +def test_network_access_requests_use_independent_api_url_when_configured() -> None: + gateway, http_client = _gateway() response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()} + http_client.request.return_value = response - config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1/") - with patch("services.billing_service._http_client.request", return_value=response) as request: - BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) + gateway.list_groups(TENANT_ID, ACCOUNT_ID) - request.assert_called_once_with( + http_client.request.assert_called_once_with( "GET", - f"https://saas.internal/v1/tenants/{TENANT_ID}/network-access-groups", + f"https://network-access.internal/v1/tenants/{TENANT_ID}/network-access-groups", json=None, params={"actor_account_id": ACCOUNT_ID}, headers=HEADERS, @@ -44,15 +52,15 @@ def test_network_access_requests_use_independent_api_url_when_configured( ) -@pytest.mark.usefixtures("billing_config") -def test_network_access_requests_fall_back_to_billing_api_url_at_request_time() -> None: +def test_network_access_requests_fall_back_to_billing_api_url() -> None: + gateway, http_client = _gateway(base_url="") response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()} + http_client.request.return_value = response - with patch("services.billing_service._http_client.request", return_value=response) as request: - BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) + gateway.list_groups(TENANT_ID, ACCOUNT_ID) - request.assert_called_once_with( + http_client.request.assert_called_once_with( "GET", f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", json=None, @@ -62,40 +70,21 @@ def test_network_access_requests_fall_back_to_billing_api_url_at_request_time() ) -@pytest.mark.usefixtures("billing_config") -def test_subscription_requests_keep_using_billing_api_url(config_overrides: Callable[..., None]) -> None: - response = MagicMock(status_code=httpx.codes.OK) - response.json.return_value = {"subscription": {"plan": "professional"}} - - config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1") - with patch("services.billing_service._http_client.request", return_value=response) as request: - BillingService._send_request("GET", "/subscription/info", params={"tenant_id": TENANT_ID}) - - request.assert_called_once_with( - "GET", - "https://billing.internal/v1/subscription/info", - json=None, - params={"tenant_id": TENANT_ID}, - headers=HEADERS, - follow_redirects=True, - ) - - -@pytest.mark.usefixtures("billing_config") def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None: + gateway, http_client = _gateway(base_url="") response = MagicMock(status_code=httpx.codes.OK) response.json.side_effect = [ {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}, {"group": {"id": GROUP_ID}}, ] + http_client.request.return_value = response - with patch("services.billing_service._http_client.request", return_value=response) as request: - list_result = BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID) - item_result = BillingService.get_network_access_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + list_result = gateway.list_groups(TENANT_ID, ACCOUNT_ID) + item_result = gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) assert list_result["groups"] == [] assert item_result["group"]["id"] == GROUP_ID - assert request.call_args_list == [ + assert http_client.request.call_args_list == [ call( "GET", f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", @@ -115,40 +104,40 @@ def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None: ] -@pytest.mark.usefixtures("billing_config") def test_group_mutations_inject_actor_and_use_expected_version() -> None: + gateway, http_client = _gateway(base_url="") response = MagicMock(status_code=httpx.codes.OK) response.json.side_effect = [ {"group": {"id": GROUP_ID, "version": "1"}}, {"group": {"id": GROUP_ID, "version": "2"}}, {"deleted": True}, ] + http_client.request.return_value = response + + gateway.create_group( + TENANT_ID, + name="Office", + description="Office egress", + allowed_cidrs=["203.0.113.7/32"], + actor_account_id=ACCOUNT_ID, + ) + gateway.update_group( + TENANT_ID, + GROUP_ID, + name="Office", + description="Updated office egress", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + gateway.delete_group( + TENANT_ID, + GROUP_ID, + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) - with patch("services.billing_service._http_client.request", return_value=response) as request: - BillingService.create_network_access_group( - TENANT_ID, - name="Office", - description="Office egress", - allowed_cidrs=["203.0.113.7/32"], - actor_account_id=ACCOUNT_ID, - ) - BillingService.update_network_access_group( - TENANT_ID, - GROUP_ID, - name="Office", - description="Updated office egress", - allowed_cidrs=["203.0.113.0/24"], - expected_version=1, - actor_account_id=ACCOUNT_ID, - ) - BillingService.delete_network_access_group( - TENANT_ID, - GROUP_ID, - expected_version=2, - actor_account_id=ACCOUNT_ID, - ) - - assert request.call_args_list == [ + assert http_client.request.call_args_list == [ call( "POST", f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", @@ -187,8 +176,8 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None: ] -@pytest.mark.usefixtures("billing_config") def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None: + gateway, http_client = _gateway(base_url="") response = MagicMock(status_code=httpx.codes.OK) response.json.side_effect = [ {"tenant_id": TENANT_ID, "app_id": APP_ID, "entitled": True, "binding": None}, @@ -211,30 +200,30 @@ def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None: } }, ] - - with patch("services.billing_service._http_client.request", return_value=response) as request: - BillingService.get_app_network_access_group(TENANT_ID, APP_ID, ACCOUNT_ID) - BillingService.update_app_network_access_group( - TENANT_ID, - APP_ID, - enabled=True, - group_id=GROUP_ID, - access_points=["webapp", "service_api"], - expected_version=0, - actor_account_id=ACCOUNT_ID, - ) - BillingService.update_app_network_access_group( - TENANT_ID, - APP_ID, - enabled=False, - group_id=GROUP_ID, - access_points=["webapp", "service_api"], - expected_version=1, - actor_account_id=ACCOUNT_ID, - ) + http_client.request.return_value = response + + gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID) + gateway.update_app_binding( + TENANT_ID, + APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=0, + actor_account_id=ACCOUNT_ID, + ) + gateway.update_app_binding( + TENANT_ID, + APP_ID, + enabled=False, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) endpoint = f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group" - assert request.call_args_list == [ + assert http_client.request.call_args_list == [ call( "GET", endpoint, @@ -274,16 +263,16 @@ def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None: ] -@pytest.mark.usefixtures("billing_config") def test_app_lifecycle_cleanup_uses_internal_secret_endpoint() -> None: + gateway, http_client = _gateway(base_url="") response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = {"deleted": True} + http_client.request.return_value = response - with patch("services.billing_service._http_client.request", return_value=response) as request: - result = BillingService.cleanup_app_network_access_group_binding(TENANT_ID, APP_ID) + result = gateway.cleanup_app_binding(TENANT_ID, APP_ID) assert result == {"deleted": True} - request.assert_called_once_with( + http_client.request.assert_called_once_with( "DELETE", f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group-binding", json=None, @@ -295,51 +284,112 @@ def test_app_lifecycle_cleanup_uses_internal_secret_endpoint() -> None: @pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500, 503]) def test_network_access_group_request_preserves_upstream_status(status_code: int) -> None: + gateway, _ = _gateway() response = MagicMock(status_code=status_code) with ( - patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + patch.object(gateway, "_send_http_request", return_value=response), pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, ): - BillingService._send_network_access_group_request("GET", "/groups") + gateway._send_request("GET", "/groups") assert exc_info.value.status_code == status_code -def test_network_access_group_request_preserves_whitelisted_upstream_reason() -> None: +def test_network_access_group_request_preserves_upstream_reason() -> None: + gateway, _ = _gateway() response = MagicMock(status_code=httpx.codes.CONFLICT) response.json.return_value = {"code": 409, "reason": "NETWORK_ACCESS_VERSION_CONFLICT", "message": "internal"} with ( - patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + patch.object(gateway, "_send_http_request", return_value=response), pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, ): - BillingService._send_network_access_group_request("DELETE", "/groups/id") + gateway._send_request("DELETE", "/groups/id") assert exc_info.value.status_code == httpx.codes.CONFLICT assert exc_info.value.reason == "NETWORK_ACCESS_VERSION_CONFLICT" def test_network_access_group_request_maps_transport_failure_to_service_unavailable() -> None: - request = httpx.Request("GET", "https://billing.internal/v1/groups") + gateway, _ = _gateway() + request = httpx.Request("GET", "https://network-access.internal/v1/groups") with ( patch.object( - BillingService, - "_send_network_access_group_http_request", + gateway, + "_send_http_request", side_effect=httpx.ConnectError("unavailable", request=request), ), pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, ): - BillingService._send_network_access_group_request("GET", "/groups") + gateway._send_request("GET", "/groups") assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE def test_network_access_group_request_rejects_non_object_response() -> None: + gateway, _ = _gateway() response = MagicMock(status_code=httpx.codes.OK) response.json.return_value = list[object]() with ( - patch.object(BillingService, "_send_network_access_group_http_request", return_value=response), + patch.object(gateway, "_send_http_request", return_value=response), pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, ): - BillingService._send_network_access_group_request("GET", "/groups") + gateway._send_request("GET", "/groups") assert exc_info.value.status_code == httpx.codes.BAD_GATEWAY + + +@pytest.mark.parametrize("plan", [CloudPlan.PROFESSIONAL, CloudPlan.TEAM]) +def test_billing_entitlement_gateway_accepts_paid_plans(plan: CloudPlan) -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with patch( + "services.network_access_group_gateway.BillingService.get_info", + return_value={"subscription": {"plan": plan}}, + ) as get_info: + assert gateway.is_paid_plan(TENANT_ID) is True + + get_info.assert_called_once_with(TENANT_ID, exclude_vector_space=True) + + +def test_billing_entitlement_gateway_rejects_unpaid_plan() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with patch( + "services.network_access_group_gateway.BillingService.get_info", + return_value={"subscription": {"plan": CloudPlan.SANDBOX}}, + ): + assert gateway.is_paid_plan(TENANT_ID) is False + + +def test_billing_entitlement_gateway_maps_unavailable_billing_service() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=httpx.ConnectError("unavailable"), + ), + pytest.raises(NetworkAccessGroupEntitlementUnavailableError), + ): + gateway.is_paid_plan(TENANT_ID) + + +def test_billing_entitlement_gateway_maps_billing_http_status_error() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=_BillingHTTPStatusError("unavailable", 503), + ), + pytest.raises(NetworkAccessGroupEntitlementUnavailableError), + ): + gateway.is_paid_plan(TENANT_ID) + + +def test_billing_entitlement_gateway_does_not_mask_unexpected_value_error() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=ValueError("programming error"), + ), + pytest.raises(ValueError, match="programming error"), + ): + gateway.is_paid_plan(TENANT_ID) diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py new file mode 100644 index 00000000000000..2e4a948cd1a7c7 --- /dev/null +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -0,0 +1,444 @@ +from collections.abc import Callable +from typing import NamedTuple +from unittest.mock import MagicMock, create_autospec + +import pytest + +from machinery.context import RequestContext +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupAppQuery, + NetworkAccessGroupAppQueryError, + NetworkAccessGroupAppRecord, + NetworkAccessGroupControlPlane, + NetworkAccessGroupEntitlement, + NetworkAccessGroupService, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + WorkspaceMembershipRoleQuery, +) + +WORKSPACE_ID = "workspace-1" +ACCOUNT_ID = "account-1" +APP_ID = "app-1" +GROUP_ID = "group-1" + + +class _Harness(NamedTuple): + service: NetworkAccessGroupService + control_plane: MagicMock + apps: MagicMock + memberships: MagicMock + entitlement: MagicMock + + +def _context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id=None, + account_id=ACCOUNT_ID, + active_workspace_id=WORKSPACE_ID, + ) + + +def _app(*, app_id: str = APP_ID, mode: str = "workflow", name: str = "App") -> NetworkAccessGroupAppRecord: + return NetworkAccessGroupAppRecord( + id=app_id, + mode=mode, + name=name, + icon=f"{app_id}.png", + icon_type="image", + icon_background="#FFFFFF", + ) + + +def _harness(*, role: str | None = "owner", paid: bool = True, app_mode: str = "workflow") -> _Harness: + control_plane = create_autospec(NetworkAccessGroupControlPlane, instance=True, spec_set=True) + apps = create_autospec(NetworkAccessGroupAppQuery, instance=True, spec_set=True) + memberships = create_autospec(WorkspaceMembershipRoleQuery, instance=True, spec_set=True) + entitlement = create_autospec(NetworkAccessGroupEntitlement, instance=True, spec_set=True) + memberships.get_role_for_account.return_value = role + entitlement.is_paid_plan.return_value = paid + apps.get_manageable_app.return_value = _app(mode=app_mode) + return _Harness( + service=NetworkAccessGroupService( + control_plane=control_plane, + apps=apps, + memberships=memberships, + entitlement=entitlement, + ), + control_plane=control_plane, + apps=apps, + memberships=memberships, + entitlement=entitlement, + ) + + +@pytest.mark.parametrize("role", ["normal", None]) +def test_reads_reject_non_privileged_persisted_roles_before_control_plane(role: str | None) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + harness.service.list_groups(_context()) + + harness.memberships.get_role_for_account.assert_called_once_with( + workspace_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + assert harness.control_plane.method_calls == [] + + +Mutation = Callable[[NetworkAccessGroupService, RequestContext], object] + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param( + lambda service, context: service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ), + id="create-group", + ), + pytest.param( + lambda service, context: service.update_group( + context, + group_id=GROUP_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ), + id="update-group", + ), + pytest.param( + lambda service, context: service.delete_group( + context, + group_id=GROUP_ID, + expected_version=1, + ), + id="delete-group", + ), + pytest.param( + lambda service, context: service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ), + id="update-app-binding", + ), + ], +) +@pytest.mark.parametrize("role", ["normal", None]) +def test_mutations_reject_non_privileged_persisted_roles_before_control_plane( + role: str | None, + mutation: Mutation, +) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + mutation(harness.service, _context()) + + harness.memberships.get_role_for_account.assert_called_once_with( + workspace_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + assert harness.control_plane.method_calls == [] + + +@pytest.mark.parametrize("role", ["owner", "admin"]) +def test_reads_allow_persisted_owner_and_admin(role: str) -> None: + harness = _harness(role=role) + harness.control_plane.list_groups.return_value = {"entitled": True, "groups": list[object]()} + + harness.service.list_groups(_context()) + + harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID) + + +def test_mutation_does_not_repeat_http_paid_plan_admission() -> None: + harness = _harness() + harness.control_plane.create_group.return_value = {"group": {"id": GROUP_ID}} + + harness.service.create_group( + _context(), + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ) + + harness.entitlement.is_paid_plan.assert_not_called() + + +def test_public_use_cases_forward_workspace_actor_and_request_parameters() -> None: + harness = _harness() + harness.control_plane.list_groups.return_value = {"entitled": True, "groups": list[object]()} + harness.control_plane.create_group.return_value = {"group": {"id": GROUP_ID}} + harness.control_plane.get_group.return_value = {"group": {"id": GROUP_ID}} + harness.control_plane.update_group.return_value = {"group": {"id": GROUP_ID}} + harness.control_plane.delete_group.return_value = {"deleted": True} + harness.control_plane.get_app_binding.return_value = {"entitled": True, "binding": None} + harness.control_plane.update_app_binding.return_value = dict[str, object](binding=None) + context = _context() + + harness.service.list_groups(context) + harness.service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ) + harness.service.get_group(context, group_id=GROUP_ID) + harness.service.update_group( + context, + group_id=GROUP_ID, + name="Office v2", + description="Updated network", + allowed_cidrs=["203.0.113.7/32"], + expected_version=2, + ) + harness.service.delete_group(context, group_id=GROUP_ID, expected_version=3) + harness.service.get_app_binding(context, app_id=APP_ID) + harness.service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=4, + ) + + harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID) + harness.control_plane.create_group.assert_called_once_with( + WORKSPACE_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.get_group.assert_called_once_with(WORKSPACE_ID, GROUP_ID, ACCOUNT_ID) + harness.control_plane.update_group.assert_called_once_with( + WORKSPACE_ID, + GROUP_ID, + name="Office v2", + description="Updated network", + allowed_cidrs=["203.0.113.7/32"], + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.delete_group.assert_called_once_with( + WORKSPACE_ID, + GROUP_ID, + expected_version=3, + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.update_app_binding.assert_called_once_with( + WORKSPACE_ID, + APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=4, + actor_account_id=ACCOUNT_ID, + ) + + +def test_cleanup_app_binding_forwards_without_console_admission() -> None: + harness = _harness() + harness.control_plane.cleanup_app_binding.return_value = {"deleted": True} + + result = harness.service.cleanup_app_binding(workspace_id=WORKSPACE_ID, app_id=APP_ID) + + assert result == {"deleted": True} + harness.control_plane.cleanup_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID) + harness.memberships.get_role_for_account.assert_not_called() + harness.entitlement.is_paid_plan.assert_not_called() + + +@pytest.mark.parametrize( + ("upstream_entitled", "paid", "expected"), + [ + (False, False, False), + (False, True, False), + (True, False, False), + (True, True, True), + ], +) +def test_list_effective_entitlement_requires_both_sources( + upstream_entitled: bool, + paid: bool, + expected: bool, +) -> None: + harness = _harness(paid=paid) + harness.control_plane.list_groups.return_value = { + "entitled": upstream_entitled, + "groups": list[object](), + } + + result = harness.service.list_groups(_context()) + + assert result["entitled"] is expected + + +@pytest.mark.parametrize( + ("mode", "available_access_points"), + [ + ("workflow", ["webapp", "service_api", "mcp", "trigger"]), + ("advanced-chat", ["webapp", "service_api", "mcp"]), + ("chat", ["webapp", "service_api", "mcp"]), + ("completion", ["webapp", "service_api", "mcp"]), + ("agent-chat", ["webapp", "service_api", "mcp"]), + ("agent", ["webapp", "service_api"]), + ], +) +def test_app_binding_reports_access_points_for_each_supported_mode( + mode: str, + available_access_points: list[str], +) -> None: + harness = _harness(app_mode=mode) + harness.control_plane.get_app_binding.return_value = {"entitled": True, "binding": None} + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result["available_access_points"] == available_access_points + + +def test_app_binding_rejects_unsupported_app_mode_before_control_plane() -> None: + harness = _harness(app_mode="site") + + with pytest.raises(NetworkAccessGroupUnsupportedAppModeError, match="'site'"): + harness.service.get_app_binding(_context(), app_id=APP_ID) + + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_binding_rejects_missing_or_inaccessible_app_before_control_plane() -> None: + harness = _harness() + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.get_app_binding(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.memberships.get_role_for_account.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_update_app_binding_rejects_missing_or_inaccessible_app_before_control_plane() -> None: + harness = _harness() + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.memberships.get_role_for_account.assert_not_called() + harness.control_plane.update_app_binding.assert_not_called() + + +def test_update_app_binding_rejects_access_points_not_supported_by_mode() -> None: + harness = _harness(app_mode="agent") + + with pytest.raises(NetworkAccessGroupUnsupportedAccessPointsError) as exc_info: + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["trigger", "mcp"], + expected_version=1, + ) + + assert exc_info.value.access_points == ("mcp", "trigger") + harness.control_plane.update_app_binding.assert_not_called() + + +def test_app_binding_materializes_protojson_defaults() -> None: + harness = _harness(app_mode="chat") + harness.control_plane.get_app_binding.return_value = { + "entitled": True, + "binding": dict[str, object](), + } + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result["binding"] == {"enabled": False, "access_points": []} + + +def test_app_binding_normalizes_camel_case_and_filters_stale_access_points() -> None: + harness = _harness(app_mode="chat") + harness.control_plane.get_app_binding.return_value = { + "entitled": True, + "binding": {"enabled": True, "accessPoints": ["webapp", "trigger", "mcp"]}, + } + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result["binding"] == {"enabled": True, "access_points": ["webapp", "mcp"]} + + +def test_group_payloads_are_enriched_with_tenant_scoped_app_metadata() -> None: + harness = _harness() + harness.control_plane.list_groups.return_value = { + "entitled": True, + "groups": [ + { + "id": "group-1", + "usedByAppIds": ["app-2", "missing-app", "app-1"], + "usedByCount": "3", + }, + {"id": "group-2", "app_ids": ["app-1"]}, + ], + } + harness.apps.list_apps.return_value = [ + _app(app_id="app-1", name="One"), + _app(app_id="app-2", name="Two"), + ] + + result = harness.service.list_groups(_context()) + + groups = result["groups"] + assert groups[0]["app_ids"] == ["app-2", "missing-app", "app-1"] + assert groups[0]["used_by_count"] == "3" + assert [app["id"] for app in groups[0]["apps"]] == ["app-2", "app-1"] + assert groups[1]["used_by_count"] == 1 + assert groups[1]["apps"] == [ + { + "id": "app-1", + "name": "One", + "icon": "app-1.png", + "icon_type": "image", + "icon_background": "#FFFFFF", + } + ] + call_kwargs = harness.apps.list_apps.call_args.kwargs + assert call_kwargs["workspace_id"] == WORKSPACE_ID + assert set(call_kwargs["app_ids"]) == {"app-1", "app-2", "missing-app"} + + +def test_app_query_failure_degrades_group_enrichment_to_empty_apps() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = { + "group": {"id": GROUP_ID, "used_by_app_ids": [APP_ID], "used_by_count": 1} + } + harness.apps.list_apps.side_effect = NetworkAccessGroupAppQueryError + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result["group"]["app_ids"] == [APP_ID] + assert result["group"]["apps"] == [] diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index e489c0598c5292..30e6568407ee9b 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,5 +1,6 @@ import logging from datetime import UTC, datetime +from types import SimpleNamespace from unittest.mock import MagicMock, call, patch from uuid import uuid4 @@ -14,7 +15,7 @@ from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowArchiveLog -from services.billing_service import NetworkAccessGroupUpstreamError +from services.network_access_group_service import NetworkAccessGroupUpstreamError from tasks.remove_app_and_related_data_task import ( _delete_app_stars, _delete_app_workflow_archive_logs, @@ -152,17 +153,20 @@ def test_community_app_cleanup_does_not_schedule_binding_cleanup(monkeypatch: py def test_independent_binding_cleanup_task_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: - cleanup = MagicMock(return_value={"deleted": True}) + service = MagicMock() + service.cleanup_app_binding.return_value = {"deleted": True} apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) - monkeypatch.setattr( - remove_app_task_module.BillingService, - "cleanup_app_network_access_group_binding", - cleanup, - ) - remove_app_task_module.cleanup_app_network_access_group_binding_task.run(tenant_id="tenant-1", app_id="app-1") + with patch( + "extensions.ext_application_services.application_services", + return_value=SimpleNamespace(network_access_groups=service), + ): + remove_app_task_module.cleanup_app_network_access_group_binding_task.run( + tenant_id="tenant-1", + app_id="app-1", + ) - cleanup.assert_called_once_with("tenant-1", "app-1") + service.cleanup_app_binding.assert_called_once_with(workspace_id="tenant-1", app_id="app-1") assert remove_app_task_module.cleanup_app_network_access_group_binding_task.max_retries == 24 @@ -172,16 +176,22 @@ def test_independent_binding_cleanup_task_retries_with_exponential_backoff( upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_APP_STILL_EXISTS") retry_error = RuntimeError("retry scheduled") retry = MagicMock(side_effect=retry_error) + service = MagicMock() + service.cleanup_app_binding.side_effect = upstream_error apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) - monkeypatch.setattr( - remove_app_task_module.BillingService, - "cleanup_app_network_access_group_binding", - MagicMock(side_effect=upstream_error), - ) monkeypatch.setattr(remove_app_task_module.cleanup_app_network_access_group_binding_task, "retry", retry) - with pytest.raises(RuntimeError, match="retry scheduled"): - remove_app_task_module.cleanup_app_network_access_group_binding_task.run(tenant_id="tenant-1", app_id="app-1") + with ( + patch( + "extensions.ext_application_services.application_services", + return_value=SimpleNamespace(network_access_groups=service), + ), + pytest.raises(RuntimeError, match="retry scheduled"), + ): + remove_app_task_module.cleanup_app_network_access_group_binding_task.run( + tenant_id="tenant-1", + app_id="app-1", + ) retry.assert_called_once_with(exc=upstream_error, countdown=15) assert remove_app_task_module._network_access_binding_cleanup_retry_delay(5) == 480 From f6a7e64dbdc38476ca5b0c9789c791c3877ff653 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 10 Sep 2026 17:33:59 +0800 Subject: [PATCH 021/108] feat(web): persist Access Control through network access groups Keep Studio Access Control as a draft until Save, including the master switch. Show referenced apps when editing or deleting an IP policy, and bind workspace policies to apps through the console network access APIs. --- .../app/access-point/__tests__/index.spec.tsx | 2 + .../__tests__/chip-status.spec.ts | 24 +- .../__tests__/config-panel.spec.tsx | 8 +- .../access-control/__tests__/draft.spec.ts | 29 ++- .../access-control/__tests__/index.spec.tsx | 141 +++++++++-- .../__tests__/network-access.spec.ts | 106 ++++++++ .../access-control/chip-status.ts | 9 +- .../access-control/config-panel.tsx | 12 +- .../app/access-point/access-control/draft.ts | 80 ++---- .../app/access-point/access-control/index.tsx | 230 ++++++++++++++---- .../access-control/network-access.ts | 109 +++++++++ .../access-control/policy-field.tsx | 13 +- .../access-control/status-panel.tsx | 48 +++- .../account-setting/__tests__/index.spec.tsx | 21 +- .../header/account-setting/index.tsx | 10 +- .../ip-policies-page/__tests__/index.spec.tsx | 133 +++++++++- .../__tests__/policy-dialog.spec.tsx | 4 +- .../__tests__/validate-ip-entry.spec.ts | 42 +++- .../ip-policies-page/index.tsx | 160 +++++++++++- .../ip-policies-page/policy-dialog.tsx | 37 ++- .../ip-policies-page/policy-item.tsx | 153 ++++++++++++ .../ip-policies-page/referenced-apps.tsx | 56 +++++ .../ip-policies-page/validate-ip-entry.ts | 68 +++++- web/i18n/ar-TN/common.json | 8 + web/i18n/de-DE/common.json | 8 + web/i18n/en-US/common.json | 8 + web/i18n/es-ES/common.json | 8 + web/i18n/fa-IR/common.json | 8 + web/i18n/fr-FR/common.json | 8 + web/i18n/hi-IN/common.json | 8 + web/i18n/id-ID/common.json | 8 + web/i18n/it-IT/common.json | 8 + web/i18n/ja-JP/common.json | 8 + web/i18n/ko-KR/common.json | 8 + web/i18n/lo-LA/common.json | 8 + web/i18n/nl-NL/common.json | 8 + web/i18n/pl-PL/common.json | 8 + web/i18n/pt-BR/common.json | 8 + web/i18n/ro-RO/common.json | 8 + web/i18n/ru-RU/common.json | 8 + web/i18n/sl-SI/common.json | 8 + web/i18n/th-TH/common.json | 8 + web/i18n/tr-TR/common.json | 8 + web/i18n/uk-UA/common.json | 8 + web/i18n/vi-VN/common.json | 8 + web/i18n/zh-Hans/common.json | 8 + web/i18n/zh-Hant/common.json | 8 + web/service/console/query-policies.ts | 49 ++++ web/test/console/network-access.ts | 66 +++++ 49 files changed, 1617 insertions(+), 185 deletions(-) create mode 100644 web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts create mode 100644 web/app/components/app/access-point/access-control/network-access.ts create mode 100644 web/app/components/header/account-setting/ip-policies-page/policy-item.tsx create mode 100644 web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx create mode 100644 web/test/console/network-access.ts diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index e4abe18a30a5da..c2fdb7b9c9b461 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' import { seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { render } from '@/test/console/render' @@ -108,6 +109,7 @@ const renderAccessPoint = ({ } = {}) => { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) + seedCurrentWorkspaceQuery(queryClient) seedSystemFeatures(queryClient) const queryOptions = consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ diff --git a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts index 8fabb749713e17..8b0bb0ce6daaae 100644 --- a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts @@ -31,20 +31,30 @@ describe('getInServiceCoverage', () => { }) describe('getAccessControlChipState', () => { - it('returns pro for Cloud sandbox', () => { + it('returns pro when the workspace is not entitled and has no assignment', () => { expect( getAccessControlChipState({ - plan: 'sandbox', - assignment, + entitled: false, + assignment: null, availability, }).kind, ).toBe('pro') }) + it('keeps the saved assignment visible when the workspace is not entitled', () => { + expect( + getAccessControlChipState({ + entitled: false, + assignment, + availability, + }).kind, + ).toBe('on') + }) + it('returns off when the app has never been configured', () => { expect( getAccessControlChipState({ - plan: 'professional', + entitled: true, assignment: null, availability, }), @@ -54,7 +64,7 @@ describe('getAccessControlChipState', () => { it('returns paused when a saved policy is not enforcing', () => { expect( getAccessControlChipState({ - plan: 'professional', + entitled: true, assignment: { ...assignment, enabled: false }, availability, }), @@ -69,7 +79,7 @@ describe('getAccessControlChipState', () => { it('returns on when every in-service access point is covered', () => { expect( getAccessControlChipState({ - plan: 'team', + entitled: true, assignment, availability, }), @@ -84,7 +94,7 @@ describe('getAccessControlChipState', () => { it('returns partial when some in-service access points are excluded', () => { expect( getAccessControlChipState({ - plan: 'professional', + entitled: true, assignment: { ...assignment, scopes: { ...assignment.scopes, mcp: false }, diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index a24e085e94d863..3f210e7edbb2b5 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -22,12 +22,12 @@ const policies: AccessControlPolicy[] = [ { id: 'internal-network', name: 'Internal Network', - addresses: ['203.0.113.42', '198.51.100.0/24', '192.0.2.1', '192.0.2.2'], + allowed_cidrs: ['203.0.113.42/32', '198.51.100.0/24', '192.0.2.1/32', '192.0.2.2/32'], }, { id: 'office-vpn', name: 'Office VPN', - addresses: ['198.51.100.0/24'], + allowed_cidrs: ['198.51.100.0/24'], }, ] @@ -113,7 +113,7 @@ describe('AccessControlConfigPanel', () => { await user.click(await screen.findByRole('option', { name: /Internal Network/ })) expect( - screen.getByText('Allows 203.0.113.42, 198.51.100.0/24 and 2 more addresses'), + screen.getByText('Allows 203.0.113.42/32, 198.51.100.0/24 and 2 more addresses'), ).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( @@ -156,6 +156,7 @@ describe('AccessControlConfigPanel', () => { initialDraft={{ ...createDefaultAccessControlDraft(), selectedPolicyId: 'internal-network', + enabled: true, }} availability={{ ...allScopesAvailable, mcp: false }} />, @@ -174,6 +175,7 @@ describe('AccessControlConfigPanel', () => { initialDraft={{ ...createDefaultAccessControlDraft(), selectedPolicyId: 'internal-network', + enabled: true, }} availability={{ webApp: true, serviceApi: true, mcp: false, trigger: false }} />, diff --git a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts index f4d202d5244d13..cbf7652361d862 100644 --- a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts @@ -1,11 +1,13 @@ +import { + policyIncludesIp, + splitPolicySummary, +} from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' import { AppModeEnum } from '@/types/app' import { canSaveAccessControl, createDefaultAccessControlDraft, getAccessControlScopeAvailability, getAccessControlScopeSupport, - policyIncludesIp, - splitPolicySummary, } from '../draft' const allScopesAvailable = { @@ -35,6 +37,7 @@ describe('canSaveAccessControl', () => { canSaveAccessControl({ draft: { selectedPolicyId: 'policy-1', + enabled: true, scopes: { webApp: false, serviceApi: false, @@ -61,6 +64,7 @@ describe('canSaveAccessControl', () => { canSaveAccessControl({ draft: { selectedPolicyId: 'policy-1', + enabled: true, scopes: { webApp: false, serviceApi: false, @@ -78,6 +82,7 @@ describe('canSaveAccessControl', () => { canSaveAccessControl({ draft: { selectedPolicyId: 'policy-1', + enabled: true, scopes: { webApp: false, serviceApi: false, @@ -89,6 +94,26 @@ describe('canSaveAccessControl', () => { }), ).toBe(false) }) + + it('disables save when the draft matches the saved baseline', () => { + expect( + canSaveAccessControl({ + draft: selectedDraft, + availability: { ...allScopesAvailable, trigger: false }, + baseline: selectedDraft, + }), + ).toBe(false) + }) + + it('allows saving a pause when a policy is already selected', () => { + expect( + canSaveAccessControl({ + draft: { ...selectedDraft, enabled: false }, + availability: { ...allScopesAvailable, trigger: false }, + baseline: selectedDraft, + }), + ).toBe(true) + }) }) describe('getAccessControlScopeSupport', () => { diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 9502de6902e82e..12f9950c63d5a6 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -1,11 +1,17 @@ +import type { AppNetworkAccessGroupBindingResponse } from '@dify/contracts/api/console/apps/types.gen' import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useModalContext } from '@/context/modal-context' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { + createNetworkAccessGroupFixture, + seedAppNetworkAccessGroup, + seedNetworkAccessGroups, +} from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { AccessControlEntry } from '..' -const mockSetShowPricingModal = vi.fn() +const mockSetPricing = vi.fn() const mockSetSettingsDestination = vi.fn() const accessControlTranslations = vi.hoisted(() => ({ 'operation.cancel': 'Cancel', @@ -35,16 +41,35 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.tooltipOff': 'Not set up', 'studio.accessControl.tooltipPro': 'Access control requires the Pro plan', 'studio.accessControl.turnOn': 'Turn on Access Control', + 'studio.accessControl.restrictByIp': 'Restrict by IP address', + 'studio.accessControl.restrictedTo': 'Restricted to {{name}}', + 'studio.accessControl.protectingAll': 'Protecting all {{count}} access points in service.', + 'studio.accessControl.policySummaryTwo': 'Allows {{first}} and {{second}}', + 'operation.edit': 'Edit', + 'studio.accessControl.turnOffTitle': 'Turn off access control?', + 'studio.accessControl.turnOffDescription': '{{points}} will be reachable from any IP.', + 'studio.accessControl.turnOffConfirm': 'Turn off', })) -vi.mock('@/context/modal-context', () => ({ - useModalContext: vi.fn(), - useModalContextSelector: vi.fn(), +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: { id: string; mode: string } }) => unknown) => + selector({ + appDetail: { + id: 'app-1', + mode: 'chat', + }, + }), })) vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } + return { + ...actual, + useQueryState: (name: string) => { + if (name === 'pricing') return [null, mockSetPricing] + return [null, mockSetSettingsDestination] + }, + } }) vi.mock('react-i18next', async () => { @@ -55,11 +80,21 @@ vi.mock('react-i18next', async () => { const renderEntry = ({ plan, deploymentEdition = 'CLOUD', + groups = [], + binding = null, }: { plan?: CloudPlan deploymentEdition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' + groups?: NetworkAccessGroupResponse[] + binding?: AppNetworkAccessGroupBindingResponse | null } = {}) => { + const queryClient = createConsoleQueryClient() + const entitled = plan === 'professional' || plan === 'team' + seedNetworkAccessGroups(queryClient, { entitled, groups }) + seedAppNetworkAccessGroup(queryClient, 'app-1', { entitled, binding }) + return renderWithConsoleQuery(, { + queryClient, systemFeatures: { deployment_edition: deploymentEdition }, features: plan ? { @@ -76,17 +111,6 @@ const getChip = () => screen.getByRole('button', { name: /Access Control/ }) describe('AccessControlEntry', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useModalContext).mockReturnValue({ - hasBlockingModalOpen: false, - setShowModerationSettingModal: vi.fn(), - setShowExternalDataToolModal: vi.fn(), - setShowPricingModal: mockSetShowPricingModal, - setShowAnnotationFullModal: vi.fn(), - setShowModelModal: vi.fn(), - setShowExternalKnowledgeAPIModal: vi.fn(), - setShowOpeningModal: vi.fn(), - setShowUpdatePluginModal: vi.fn(), - }) }) it('does not render on community edition', () => { @@ -101,6 +125,31 @@ describe('AccessControlEntry', () => { expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() }) + it('shows a saved assignment on Cloud sandbox instead of hiding it behind the paywall', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'sandbox', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + expect(within(getChip()).getByText('ON')).toBeInTheDocument() + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Turn on Access Control' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + }) + it('renders a single chip with a non-interactive PRO badge for Cloud sandbox', () => { renderEntry({ plan: 'sandbox' }) @@ -138,7 +187,7 @@ describe('AccessControlEntry', () => { expect(turnOn).not.toHaveTextContent('PRO') await user.click(turnOn) - expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + expect(mockSetPricing).toHaveBeenCalledWith('open') }) it('closes the paywall on Escape without side effects', async () => { @@ -155,7 +204,7 @@ describe('AccessControlEntry', () => { screen.queryByText('Restrict this app to IP addresses you trust.'), ).not.toBeInTheDocument() }) - expect(mockSetShowPricingModal).not.toHaveBeenCalled() + expect(mockSetPricing).not.toHaveBeenCalled() }) it('opens the first-time config popover for paid workspaces without a back control', async () => { @@ -209,6 +258,56 @@ describe('AccessControlEntry', () => { expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() }) expect(mockSetSettingsDestination).not.toHaveBeenCalled() - expect(mockSetShowPricingModal).not.toHaveBeenCalled() + expect(mockSetPricing).not.toHaveBeenCalled() + }) + + it('shows the saved binding status for a paid workspace', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + expect(within(getChip()).getByText('ON')).toBeInTheDocument() + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toBeChecked() + }) + + it('keeps the saved chip state until a turned-off draft is saved', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + await user.click(screen.getByRole('button', { name: 'Turn off' })) + + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() }) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts new file mode 100644 index 00000000000000..9856b34c8439ac --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts @@ -0,0 +1,106 @@ +import { + accessPointsFromScopes, + availabilityFromAccessPoints, + draftFromBinding, + getNetworkAccessErrorStatus, + scopesFromAccessPoints, + toApiAccessPoint, + toUiAccessPoint, +} from '../network-access' + +const allAvailable = { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, +} + +describe('network access mappers', () => { + it('maps UI access points to the Console contract', () => { + expect(toApiAccessPoint('webApp')).toBe('webapp') + expect(toApiAccessPoint('serviceApi')).toBe('service_api') + expect(toUiAccessPoint('webapp')).toBe('webApp') + expect(toUiAccessPoint('service_api')).toBe('serviceApi') + }) + + it('builds availability from the server access-point matrix', () => { + expect(availabilityFromAccessPoints(['webapp', 'service_api', 'mcp'], true)).toEqual({ + webApp: true, + serviceApi: true, + mcp: true, + trigger: false, + }) + expect( + availabilityFromAccessPoints(['webapp', 'service_api', 'mcp', 'trigger'], false), + ).toEqual({ + webApp: true, + serviceApi: true, + mcp: true, + trigger: false, + }) + }) + + it('reads HTTP status from Response and nested response objects', () => { + expect(getNetworkAccessErrorStatus(new Response(null, { status: 409 }))).toBe(409) + expect(getNetworkAccessErrorStatus({ response: { status: 409 } })).toBe(409) + }) + + it('builds scopes from selected API access points and available ones', () => { + expect(scopesFromAccessPoints(['webapp', 'mcp'], allAvailable)).toEqual({ + webApp: true, + serviceApi: false, + mcp: true, + trigger: false, + }) + }) + + it('omits unavailable scopes when assembling a PUT payload', () => { + expect( + accessPointsFromScopes( + { webApp: true, serviceApi: true, mcp: true, trigger: true }, + { ...allAvailable, trigger: false }, + ), + ).toEqual(['webapp', 'service_api', 'mcp']) + }) + + it('creates an empty draft when the app has no binding', () => { + expect(draftFromBinding(null, allAvailable)).toEqual({ + selectedPolicyId: null, + enabled: true, + scopes: { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, + }, + }) + }) + + it('hydrates a draft from a bound group', () => { + expect( + draftFromBinding( + { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api'], + version: 3, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + allAvailable, + ), + ).toEqual({ + selectedPolicyId: 'group-1', + enabled: true, + scopes: { + webApp: true, + serviceApi: true, + mcp: false, + trigger: false, + }, + }) + }) +}) diff --git a/web/app/components/app/access-point/access-control/chip-status.ts b/web/app/components/app/access-point/access-control/chip-status.ts index afd3b87adb2626..557c050fe2d958 100644 --- a/web/app/components/app/access-point/access-control/chip-status.ts +++ b/web/app/components/app/access-point/access-control/chip-status.ts @@ -32,19 +32,16 @@ export function getInServiceCoverage( } export function getAccessControlChipState({ - plan, + entitled, assignment, availability, }: { - plan: string | undefined + entitled?: boolean assignment: AccessControlAssignment | null availability: AccessControlScopeAvailability }): AccessControlChipState { - if (plan === 'sandbox') { - return { kind: 'pro', coveredCount: 0, inServiceCount: 0 } - } - if (!assignment) { + if (entitled === false) return { kind: 'pro', coveredCount: 0, inServiceCount: 0 } return { kind: 'off', coveredCount: 0, inServiceCount: 0 } } diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index 27f3b0d797c5cf..9ae1ac637d8c1f 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -12,7 +12,7 @@ import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { IconButton } from '@langgenius/dify-ui/icon-button' import { PopoverTitle } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' -import { canSaveAccessControl } from './draft' +import { canSaveAccessControl, hasSelectedProtectableAccessPoint } from './draft' import { AccessControlPolicyField } from './policy-field' import { AccessControlScopeList } from './scope-list' @@ -21,7 +21,9 @@ type AccessControlConfigPanelProps = { policies: readonly AccessControlPolicy[] currentIp?: string availability: AccessControlScopeAvailability + baseline?: AccessControlDraft showBack?: boolean + saving?: boolean onBack?: () => void onCancel: () => void onCreatePolicy: () => void @@ -35,7 +37,9 @@ export function AccessControlConfigPanel({ policies, currentIp, availability, + baseline, showBack = false, + saving = false, onBack, onCancel, onCreatePolicy, @@ -46,7 +50,7 @@ export function AccessControlConfigPanel({ const { t } = useTranslation() const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const hasSelectedPolicy = Boolean(draft.selectedPolicyId) - const canSave = canSaveAccessControl({ draft, availability }) + const canSave = canSaveAccessControl({ draft, availability, baseline }) const handleSubmit = (event: FormEvent) => { event.preventDefault() @@ -110,7 +114,7 @@ export function AccessControlConfigPanel({ availability={availability} onDraftChange={onDraftChange} /> - {hasSelectedPolicy && !canSave && ( + {hasSelectedPolicy && !hasSelectedProtectableAccessPoint(draft, availability) && (

{t(($) => $['studio.accessControl.selectAccessPoint'], { ns: 'deployments' })}

@@ -121,7 +125,7 @@ export function AccessControlConfigPanel({ -
diff --git a/web/app/components/app/access-point/access-control/draft.ts b/web/app/components/app/access-point/access-control/draft.ts index 4f2d266551da0c..947f4b6119ca5f 100644 --- a/web/app/components/app/access-point/access-control/draft.ts +++ b/web/app/components/app/access-point/access-control/draft.ts @@ -1,24 +1,23 @@ +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { AppModeEnum } from '@/types/app' export type AccessControlScopeAvailability = Record -export type AccessControlPolicy = { - id: string - name: string - addresses: readonly string[] -} +export type AccessControlPolicy = Pick export type AccessControlDraft = { selectedPolicyId: string | null scopes: Record + enabled: boolean } export const createDefaultAccessControlDraft = ( availability?: AccessControlScopeAvailability, ): AccessControlDraft => ({ selectedPolicyId: null, + enabled: true, scopes: { webApp: availability?.webApp ?? true, serviceApi: availability?.serviceApi ?? true, @@ -65,61 +64,34 @@ export function isProtectableAccessPoint( return availability[scope] } +export function isAccessControlDraftEqual(left: AccessControlDraft, right: AccessControlDraft) { + if (left.selectedPolicyId !== right.selectedPolicyId) return false + if (left.enabled !== right.enabled) return false + + return ACCESS_POINT_ORDER.every((scope) => left.scopes[scope] === right.scopes[scope]) +} + +export function hasSelectedProtectableAccessPoint( + draft: AccessControlDraft, + availability: AccessControlScopeAvailability, +) { + return ACCESS_POINT_ORDER.some((scope) => { + if (!isProtectableAccessPoint(scope, availability)) return false + return draft.scopes[scope] + }) +} + export function canSaveAccessControl({ draft, availability, + baseline, }: { draft: AccessControlDraft availability: AccessControlScopeAvailability + baseline?: AccessControlDraft }) { + if (baseline && isAccessControlDraftEqual(draft, baseline)) return false + if (!draft.enabled) return Boolean(draft.selectedPolicyId) if (!draft.selectedPolicyId) return false - - return ACCESS_POINT_ORDER.some((scope) => { - if (!isProtectableAccessPoint(scope, availability)) return false - return draft.scopes[scope] - }) -} - -export function splitPolicySummary(addresses: readonly string[]): { - listed: readonly string[] - moreCount: number -} { - if (addresses.length <= 2) return { listed: addresses, moreCount: 0 } - return { listed: addresses.slice(0, 2), moreCount: addresses.length - 2 } -} - -function ipv4ToInt(ip: string): number | null { - const parts = ip.split('.') - if (parts.length !== 4) return null - - let value = 0 - for (const part of parts) { - if (!/^\d+$/.test(part)) return null - const octet = Number(part) - if (octet > 255) return null - value = (value << 8) + octet - } - - return value >>> 0 -} - -function ipv4InCidr(ip: string, network: string, prefix: number): boolean { - const ipInt = ipv4ToInt(ip) - const networkInt = ipv4ToInt(network) - if (ipInt === null || networkInt === null) return false - if (prefix === 0) return true - const mask = prefix === 32 ? 0xffffffff : ~((1 << (32 - prefix)) - 1) >>> 0 - return (ipInt & mask) === (networkInt & mask) -} - -export function policyIncludesIp(addresses: readonly string[], ip: string): boolean { - return addresses.some((entry) => { - if (entry === ip) return true - const slash = entry.indexOf('/') - if (slash === -1) return false - const network = entry.slice(0, slash) - const prefix = Number(entry.slice(slash + 1)) - if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false - return ipv4InCidr(ip, network, prefix) - }) + return hasSelectedProtectableAccessPoint(draft, availability) } diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index 24fc1f99f0143d..11853025437772 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -1,20 +1,24 @@ 'use client' import type { AccessControlAssignment } from './chip-status' -import type { AccessControlDraft, AccessControlPolicy } from './draft' +import type { AccessControlDraft } from './draft' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useQuery } from '@tanstack/react-query' +import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' +import { + pricingQueryParamName, + pricingQueryParser, +} from '@/app/components/billing/pricing/query-params' import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' -import { useModalContext } from '@/context/modal-context' +import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state' import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' import { useAppWorkflow } from '@/service/use-workflow' @@ -22,61 +26,111 @@ import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' import { AccessControlChipAffix } from './chip-affix' import { getAccessControlChipState } from './chip-status' import { AccessControlConfigPanel } from './config-panel' -import { createDefaultAccessControlDraft, getAccessControlScopeAvailability } from './draft' +import { + canSaveAccessControl, + getAccessControlScopeAvailability, + isAccessControlDraftEqual, +} from './draft' import { AccessControlFreePaywall } from './free-paywall' +import { + accessPointsFromScopes, + availabilityFromAccessPoints, + draftFromBinding, + getNetworkAccessErrorStatus, + scopesFromAccessPoints, +} from './network-access' import { AccessControlStatusPanel } from './status-panel' type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void -const isPaidCloudPlan = (plan: string | undefined) => plan === 'professional' || plan === 'team' - export function AccessControlEntry() { const { t } = useTranslation() + const queryClient = useQueryClient() const deploymentEdition = useAtomValue(deploymentEditionAtom) - const { data: plan } = useQuery( - consoleQuery.features.get.queryOptions({ - enabled: deploymentEdition === 'CLOUD', - select: (data) => data.billing.subscription.plan, - }), + const isManager = useAtomValue(isCurrentWorkspaceManagerAtom) + const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) + const [_settingsDestination, setSettingsDestination] = useQueryState( + settingsQueryParamName, + settingsQueryParser, ) - const { setShowPricingModal } = useModalContext() - const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) const appInfo = useAppStore((state) => state.appDetail) - const isSandbox = plan === 'sandbox' - const isPaid = isPaidCloudPlan(plan) - const shouldFetchWorkflow = isPaid && Boolean(appInfo && isAdvancedApp(appInfo)) - const { data: workflow } = useAppWorkflow(shouldFetchWorkflow && appInfo ? appInfo.id : '') + const appId = appInfo?.id + const canFetchNetworkAccess = deploymentEdition === 'CLOUD' && isManager + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) + const { data: workflow } = useAppWorkflow( + shouldFetchWorkflow && canFetchNetworkAccess && appInfo ? appInfo.id : '', + ) + const groupsQuery = useQuery( + consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ + enabled: canFetchNetworkAccess, + retry: false, + }), + ) + const bindingQuery = useQuery( + consoleQuery.apps.byAppId.networkAccessGroup.get.queryOptions({ + input: appId ? { params: { app_id: appId } } : skipToken, + context: { silent: true }, + retry: false, + enabled: canFetchNetworkAccess && Boolean(appId), + }), + ) + const updateBinding = useMutation( + consoleQuery.apps.byAppId.networkAccessGroup.put.mutationOptions(), + ) const [open, setOpen] = useState(false) const [view, setView] = useState<'config' | 'status'>('status') const [showBack, setShowBack] = useState(false) const [draft, setDraft] = useState(null) - const [savedDraft, setSavedDraft] = useState(null) - const [enabled, setEnabled] = useState(true) - if (deploymentEdition !== 'CLOUD' || (!isSandbox && !isPaid)) return null + const bindingErrorStatus = getNetworkAccessErrorStatus(bindingQuery.error) + const hideForUnsupportedApp = + canFetchNetworkAccess && + Boolean(appId) && + (bindingErrorStatus === 400 || bindingErrorStatus === 403 || bindingErrorStatus === 404) + + if (deploymentEdition !== 'CLOUD' || !isManager) return null + if (hideForUnsupportedApp) return null + if (groupsQuery.isPending || (appId && bindingQuery.isPending)) return null const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const workflowState = appInfo ? getPublishedWorkflowState(appInfo, workflow) : undefined - const availability = getAccessControlScopeAvailability({ + const triggerAvailable = getAccessControlScopeAvailability({ mode: appInfo?.mode, hasTriggerNode: Boolean(workflowState?.hasTriggerNode), isUnpublished: Boolean(workflowState?.isUnpublished), - }) - const policies: readonly AccessControlPolicy[] = [] - const resolvedDraft = draft ?? savedDraft ?? createDefaultAccessControlDraft(availability) - const assignment: AccessControlAssignment | null = savedDraft?.selectedPolicyId + }).trigger + const availability = availabilityFromAccessPoints( + bindingQuery.data?.available_access_points ?? [], + triggerAvailable, + ) + const groups = groupsQuery.data?.groups ?? [] + const binding = bindingQuery.data?.binding + const policies = groups.map((group) => ({ + id: group.id, + name: group.name, + allowed_cidrs: group.allowed_cidrs, + })) + const baseline = draftFromBinding(binding, availability) + const resolvedDraft = draft ?? baseline + const assignment: AccessControlAssignment | null = binding?.group_id ? { - policyId: savedDraft.selectedPolicyId, - policyName: - policies.find((policy) => policy.id === savedDraft.selectedPolicyId)?.name ?? - savedDraft.selectedPolicyId, - scopes: savedDraft.scopes, - enabled, + policyId: binding.group_id, + policyName: groups.find((group) => group.id === binding.group_id)?.name ?? binding.group_id, + scopes: scopesFromAccessPoints(binding.access_points, availability), + enabled: binding.enabled, } : null - const chip = getAccessControlChipState({ plan, assignment, availability }) + const entitled = bindingQuery.data?.entitled ?? groupsQuery.data?.entitled + const canMutate = entitled === true + const dirty = !isAccessControlDraftEqual(resolvedDraft, baseline) + const canSave = canSaveAccessControl({ + draft: resolvedDraft, + availability, + baseline, + }) + const chip = getAccessControlChipState({ entitled, assignment, availability }) const showPaywall = chip.kind === 'pro' - const showStatus = Boolean(assignment) && view === 'status' + const showStatus = Boolean(assignment) && (view === 'status' || !canMutate) const tooltip = chip.kind === 'pro' @@ -101,37 +155,109 @@ export function AccessControlEntry() { : t(($) => $['studio.accessControl.tooltipOff'], { ns: 'deployments' }) const handleTurnOn = () => { - setShowPricingModal() + void setPricing('open') const gtag = (window as Window & { gtag?: GtagHandler }).gtag if (gtag) gtag('event', 'click_upgrade_btn', { loc: 'access-control-paywall' }) } const handleCancel = () => { setDraft(null) + setShowBack(false) if (assignment) { setView('status') - setShowBack(false) return } setOpen(false) } + const handleBack = () => { + setShowBack(false) + setView('status') + } + const handleCreatePolicy = () => { setOpen(false) void setSettingsDestination('ip-policies') } + const persistBinding = ( + { + enabled, + groupId, + accessPoints, + }: { + enabled: boolean + groupId: string | null + accessPoints: ReturnType + }, + onSuccess?: () => void, + ) => { + if (!appId) return + + updateBinding.mutate( + { + params: { app_id: appId }, + body: { + enabled, + group_id: groupId, + access_points: accessPoints, + expected_version: binding?.version ?? 0, + }, + }, + { + onSuccess, + onError: (error) => { + if (getNetworkAccessErrorStatus(error) !== 409) return + void queryClient.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.networkAccessGroup.get.queryKey({ + input: { params: { app_id: appId } }, + }), + }) + void queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.current.networkAccessGroups.get.queryKey(), + }) + }, + }, + ) + } + const handleSave = () => { - setSavedDraft(resolvedDraft) - setEnabled(true) - setDraft(null) - setView('status') - setShowBack(false) + if (!canMutate || !canSave || !resolvedDraft.selectedPolicyId) return + + persistBinding( + { + enabled: resolvedDraft.enabled, + groupId: resolvedDraft.selectedPolicyId, + accessPoints: accessPointsFromScopes(resolvedDraft.scopes, availability), + }, + () => { + setDraft(null) + setView('status') + setShowBack(false) + }, + ) + } + + const handleEnabledChange = (enabled: boolean) => { + if (!canMutate) return + setDraft({ ...resolvedDraft, enabled }) } return ( - + { + setOpen(nextOpen) + if (!nextOpen) { + setDraft(null) + setShowBack(false) + return + } + setView(assignment || !canMutate ? 'status' : 'config') + setShowBack(false) + }} + > @@ -151,15 +277,23 @@ export function AccessControlEntry() { > {showPaywall ? ( - ) : showStatus && savedDraft ? ( + ) : showStatus && assignment ? ( { - setDraft(savedDraft) + if (!canMutate) return + setDraft(resolvedDraft) setShowBack(true) setView('config') }} @@ -169,8 +303,10 @@ export function AccessControlEntry() { draft={resolvedDraft} policies={policies} availability={availability} + baseline={baseline} showBack={showBack} - onBack={handleCancel} + saving={updateBinding.isPending} + onBack={handleBack} onCancel={handleCancel} onCreatePolicy={handleCreatePolicy} onManagePolicies={handleCreatePolicy} diff --git a/web/app/components/app/access-point/access-control/network-access.ts b/web/app/components/app/access-point/access-control/network-access.ts new file mode 100644 index 00000000000000..df273ca56d670a --- /dev/null +++ b/web/app/components/app/access-point/access-control/network-access.ts @@ -0,0 +1,109 @@ +import type { + AppNetworkAccessGroupBindingResponse, + AppNetworkAccessGroupResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' +import { createDefaultAccessControlDraft } from './draft' + +export type NetworkAccessPoint = AppNetworkAccessGroupResponse['available_access_points'][number] + +const ACCESS_POINT_TO_API = { + webApp: 'webapp', + serviceApi: 'service_api', + mcp: 'mcp', + trigger: 'trigger', +} as const satisfies Record + +const API_TO_ACCESS_POINT = { + webapp: 'webApp', + service_api: 'serviceApi', + mcp: 'mcp', + trigger: 'trigger', +} as const satisfies Record + +export function toApiAccessPoint(accessPoint: AccessPoint): NetworkAccessPoint { + return ACCESS_POINT_TO_API[accessPoint] +} + +export function toUiAccessPoint(accessPoint: NetworkAccessPoint): AccessPoint { + return API_TO_ACCESS_POINT[accessPoint] +} + +export function availabilityFromAccessPoints( + availableAccessPoints: readonly string[], + triggerAvailable: boolean, +): AccessControlScopeAvailability { + const allowed = new Set( + availableAccessPoints.flatMap((accessPoint) => { + if (accessPoint in API_TO_ACCESS_POINT) + return [API_TO_ACCESS_POINT[accessPoint as NetworkAccessPoint]] + return [] + }), + ) + + return { + webApp: allowed.has('webApp'), + serviceApi: allowed.has('serviceApi'), + mcp: allowed.has('mcp'), + trigger: allowed.has('trigger') && triggerAvailable, + } +} + +export function getNetworkAccessErrorStatus(error: unknown): number | undefined { + if (error instanceof Response) return error.status + if (typeof error === 'object' && error !== null) { + if ('status' in error && typeof error.status === 'number') return error.status + if ( + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'status' in error.response && + typeof error.response.status === 'number' + ) + return error.response.status + } +} + +export function scopesFromAccessPoints( + accessPoints: readonly string[], + availability: AccessControlScopeAvailability, +): AccessControlDraft['scopes'] { + const selected = new Set( + accessPoints.flatMap((accessPoint) => { + if (accessPoint in API_TO_ACCESS_POINT) + return [API_TO_ACCESS_POINT[accessPoint as NetworkAccessPoint]] + return [] + }), + ) + + return { + webApp: availability.webApp && selected.has('webApp'), + serviceApi: availability.serviceApi && selected.has('serviceApi'), + mcp: availability.mcp && selected.has('mcp'), + trigger: availability.trigger && selected.has('trigger'), + } +} + +export function accessPointsFromScopes( + scopes: AccessControlDraft['scopes'], + availability: AccessControlScopeAvailability, +): NetworkAccessPoint[] { + return ACCESS_POINT_ORDER.filter( + (accessPoint) => availability[accessPoint] && scopes[accessPoint], + ).map(toApiAccessPoint) +} + +export function draftFromBinding( + binding: AppNetworkAccessGroupBindingResponse | null | undefined, + availability: AccessControlScopeAvailability, +): AccessControlDraft { + if (!binding?.group_id) return createDefaultAccessControlDraft(availability) + + return { + selectedPolicyId: binding.group_id, + enabled: binding.enabled, + scopes: scopesFromAccessPoints(binding.access_points, availability), + } +} diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx index fd07e370cbbeec..f1dfe8feb29497 100644 --- a/web/app/components/app/access-point/access-control/policy-field.tsx +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -15,7 +15,10 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' -import { policyIncludesIp, splitPolicySummary } from './draft' +import { + policyIncludesIp, + splitPolicySummary, +} from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' const MANAGE_POLICIES_VALUE = '__manage-ip-policies__' @@ -38,12 +41,12 @@ export function AccessControlPolicyField({ }: AccessControlPolicyFieldProps) { const { t } = useTranslation() const selectedPolicy = policies.find((policy) => policy.id === selectedPolicyId) - const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.addresses) : undefined + const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.allowed_cidrs) : undefined const showLockout = Boolean(selectedPolicy && currentIp) && selectedPolicy !== undefined && currentIp !== undefined && - !policyIncludesIp(selectedPolicy.addresses, currentIp) + !policyIncludesIp(selectedPolicy.allowed_cidrs, currentIp) const selectLabel = t(($) => $['studio.accessControl.ipPolicy'], { ns: 'deployments' }) const placeholder = t(($) => $['studio.accessControl.selectPolicy'], { ns: 'deployments' }) @@ -73,7 +76,7 @@ export function AccessControlPolicyField({ {t(($) => $['studio.accessControl.addressCount'], { ns: 'deployments', - count: policy.addresses.length, + count: policy.allowed_cidrs.length, })} @@ -132,7 +135,7 @@ export function AccessControlPolicyField({ {t(($) => $['studio.accessControl.addressCount'], { ns: 'deployments', - count: policy.addresses.length, + count: policy.allowed_cidrs.length, })} diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx index e887c5f583a2fe..c439fa1ea6e7e2 100644 --- a/web/app/components/app/access-point/access-control/status-panel.tsx +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -24,8 +24,9 @@ import { useTranslation } from 'react-i18next' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { useStore as useAppStore } from '@/app/components/app/store' import AppIcon from '@/app/components/base/app-icon' +import { splitPolicySummary } from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' import { getInServiceCoverage } from './chip-status' -import { isProtectableAccessPoint, splitPolicySummary } from './draft' +import { isProtectableAccessPoint } from './draft' const SCOPE_ICONS: Record, string> = { serviceApi: 'i-custom-vender-knowledge-api-aggregate', @@ -39,6 +40,13 @@ type AccessControlStatusPanelProps = { enabled: boolean availability: AccessControlScopeAvailability onEdit: () => void + updating?: boolean + dirty?: boolean + canSave?: boolean + readOnly?: boolean + onUpgrade?: () => void + onCancel?: () => void + onSave?: () => void onEnabledChange: (enabled: boolean) => void } @@ -48,13 +56,20 @@ export function AccessControlStatusPanel({ enabled, availability, onEdit, + updating = false, + dirty = false, + canSave = false, + readOnly = false, + onUpgrade, + onCancel, + onSave, onEnabledChange, }: AccessControlStatusPanelProps) { const { t } = useTranslation() const appInfo = useAppStore((state) => state.appDetail) const [confirmPause, setConfirmPause] = useState(false) const selectedPolicy = policies.find((policy) => policy.id === draft.selectedPolicyId) - const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.addresses) : undefined + const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.allowed_cidrs) : undefined const coverage = getInServiceCoverage(draft.scopes, availability) const labels: Record = { webApp: t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }), @@ -78,9 +93,15 @@ export function AccessControlStatusPanel({ }) : t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' })} - + {readOnly ? ( + + ) : ( + + )}
@@ -104,6 +125,7 @@ export function AccessControlStatusPanel({
$['studio.accessControl.restrictByIp'], { ns: 'deployments' })} onCheckedChange={(next) => { if (next) { @@ -202,6 +224,22 @@ export function AccessControlStatusPanel({ ) })}
+ {dirty && !readOnly && ( +
+ + +
+ )}
diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index f3a1470822cc8b..fffc93383b6b39 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { useState } from 'react' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { seedNetworkAccessGroups } from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { ACCOUNT_SETTING_TAB } from '../constants' import AccountSetting from '../index' @@ -189,11 +190,15 @@ describe('AccountSetting', () => { ) } + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: false, groups: [] }) + return renderWithConsoleQuery( , { + queryClient, features: { billing: { subscription: { plan: 'sandbox' } }, can_replace_logo: canReplaceLogo, @@ -566,6 +571,20 @@ describe('AccountSetting', () => { ).toBeInTheDocument() }) + it('should hide IP Policies for workspace members', () => { + mockConsoleState.current = { + ...baseConsoleState, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + } + + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IP_POLICIES }) + + expect( + screen.queryByRole('button', { name: 'common.settings.ipPolicies' }), + ).not.toBeInTheDocument() + }) + it('should change active tab when clicking on menu item', () => { // Arrange renderAccountSetting({ onTabChange: mockOnTabChange }) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 33ab1f4cbf6aa5..083a4a8ee446bc 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -74,11 +74,10 @@ export default function AccountSetting({ systemFeatures.deployment_edition === 'CLOUD' && !isCurrentWorkspaceDatasetOperator const canViewWorkflowLogArchives = systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager + const canViewIpPolicies = + systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager const activeMenu = (() => { - if ( - activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && - systemFeatures.deployment_edition !== 'CLOUD' - ) + if (activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && !canViewIpPolicies) return ACCOUNT_SETTING_TAB.MEMBERS if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling) return ACCOUNT_SETTING_TAB.PREFERENCES @@ -154,8 +153,7 @@ export default function AccountSetting({ const visibleTabs: AccountSettingTab[] = [] visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS) - if (systemFeatures.deployment_edition === 'CLOUD') - visibleTabs.push(ACCOUNT_SETTING_TAB.IP_POLICIES) + if (canViewIpPolicies) visibleTabs.push(ACCOUNT_SETTING_TAB.IP_POLICIES) if (canManageWorkspaceRoles) { visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index 6ca76bf5378d5d..8494d37e601843 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -1,11 +1,18 @@ -import { screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { render } from '@/test/console/render' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { + createNetworkAccessGroupFixture, + seedNetworkAccessGroups, +} from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import IpPoliciesPage from '..' const translations = vi.hoisted(() => ({ 'operation.cancel': 'Cancel', 'operation.close': 'Close', + 'operation.delete': 'Delete', + 'operation.edit': 'Edit', 'settings.ipPolicies': 'IP Policies', 'settings.ipPoliciesDescription': 'Reusable rules that control which IP addresses or ranges can access your apps', @@ -14,8 +21,15 @@ const translations = vi.hoisted(() => ({ 'settings.ipPolicyAllowlistHelp': 'Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.', 'settings.ipPolicyCreate': 'Create', + 'settings.ipPolicyDeleteBound': 'This policy is in use', + 'settings.ipPolicyDeleteBoundDescription': + '{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.', + 'settings.ipPolicyDeleteConfirm': 'Delete “{{name}}”?', + 'settings.ipPolicyEditUsedBy': + 'This policy is used by {{count}} apps. Saving updates the allowlist for all of them.', 'settings.ipPolicyDialogDescription': 'Specify which IP addresses or ranges can access your apps.', + 'settings.ipPolicyEditTitle': 'Edit IP Policy', 'settings.ipPolicyName': 'Name', 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', 'settings.ipPolicyNewTitle': 'New IP Policy', @@ -23,6 +37,7 @@ const translations = vi.hoisted(() => ({ 'studio.accessControl.emptyPoliciesDescription': 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', + 'studio.accessControl.policySummaryTwo': 'Allows {{first}} and {{second}}', })) vi.mock('react-i18next', async () => { @@ -33,11 +48,123 @@ vi.mock('react-i18next', async () => { describe('IpPoliciesPage', () => { it('opens the new policy dialog from Add', async () => { const user = userEvent.setup() - render() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true, groups: [] }) + renderWithConsoleQuery( + + + , + { queryClient }, + ) expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Add' })) expect(screen.getByRole('heading', { name: 'New IP Policy' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() }) + + it('lists existing policies and opens edit from the row', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [createNetworkAccessGroupFixture()], + }) + renderWithConsoleQuery( + + + , + { queryClient }, + ) + + expect(screen.getByText('Internal Network')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByRole('heading', { name: 'Edit IP Policy' })).toBeInTheDocument() + expect(screen.getByDisplayValue('Internal Network')).toBeInTheDocument() + }) + + it('warns that editing a used policy updates every referencing app', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + app_ids: ['app-a', 'app-b'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + icon: null, + icon_type: null, + icon_background: null, + }, + { id: 'app-b', name: 'Helpdesk', icon: null, icon_type: null, icon_background: null }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { queryClient }, + ) + + await user.click(screen.getByRole('button', { name: 'Edit' })) + expect( + screen.getByText( + 'This policy is used by 2 apps. Saving updates the allowlist for all of them.', + ), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/app/app-a/overview', + ) + }) + + it('lets the user delete a referenced policy after confirming the impact', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + app_ids: ['app-a', 'app-b'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + icon: null, + icon_type: null, + icon_background: null, + }, + { id: 'app-b', name: 'Helpdesk', icon: null, icon_type: null, icon_background: null }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { queryClient }, + ) + + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0] as HTMLElement) + expect(screen.getByRole('heading', { name: 'Delete “Internal Network”?' })).toBeInTheDocument() + expect( + screen.getByText( + 'Internal Network is applied to 2 apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.', + ), + ).toBeInTheDocument() + const dialog = screen.getByRole('alertdialog') + expect(within(dialog).getByRole('button', { name: 'Delete' })).toBeEnabled() + expect(within(dialog).getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/app/app-a/overview', + ) + }) }) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx index a1388915e35a57..d139e3a0a1bf9a 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx @@ -34,7 +34,7 @@ function DialogHarness({ onSubmit = vi.fn(), }: { currentIp?: string - onSubmit?: (payload: { name: string; addresses: string[] }) => void + onSubmit?: (payload: { name: string; allowed_cidrs: string[] }) => void }) { const [open, setOpen] = useState(true) return ( @@ -107,7 +107,7 @@ describe('IpPolicyDialog', () => { expect(onSubmit).toHaveBeenCalledWith({ name: 'Internal Network', - addresses: ['10.0.0.0/8', '203.0.113.42'], + allowed_cidrs: ['10.0.0.0/8', '203.0.113.42/32'], }) }) }) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts index 80740c18f725f2..7013432893bdbc 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts @@ -1,4 +1,12 @@ -import { canSubmitIpPolicy, collectIpAddresses, validateIpEntry } from '../validate-ip-entry' +import { + canSubmitIpPolicy, + collectAllowedCidrs, + collectIpAddresses, + IP_POLICY_CIDR_MAX_COUNT, + IP_POLICY_NAME_MAX_LENGTH, + toAllowedCidr, + validateIpEntry, +} from '../validate-ip-entry' describe('validateIpEntry', () => { it('treats blank input as empty, not invalid', () => { @@ -86,3 +94,35 @@ describe('collectIpAddresses', () => { ]) }) }) + +describe('toAllowedCidr', () => { + it('appends /32 to bare IPv4 addresses and /128 to bare IPv6 addresses', () => { + expect(toAllowedCidr('203.0.113.42')).toBe('203.0.113.42/32') + expect(toAllowedCidr('::1')).toBe('::1/128') + expect(toAllowedCidr('10.0.0.0/8')).toBe('10.0.0.0/8') + }) +}) + +describe('collectAllowedCidrs', () => { + it('normalizes submitted entries to CIDR notation', () => { + expect(collectAllowedCidrs([' 203.0.113.42 ', '', '10.0.0.0/8'])).toEqual([ + '203.0.113.42/32', + '10.0.0.0/8', + ]) + }) +}) + +describe('canSubmitIpPolicy limits', () => { + it('rejects names longer than the API maximum', () => { + expect(canSubmitIpPolicy('n'.repeat(IP_POLICY_NAME_MAX_LENGTH + 1), ['10.0.0.0/8'])).toBe(false) + expect(canSubmitIpPolicy('n'.repeat(IP_POLICY_NAME_MAX_LENGTH), ['10.0.0.0/8'])).toBe(true) + }) + + it('rejects more CIDRs than the API maximum', () => { + const cidrs = Array.from( + { length: IP_POLICY_CIDR_MAX_COUNT + 1 }, + (_, index) => `10.0.0.${index}`, + ) + expect(canSubmitIpPolicy('Office', cidrs)).toBe(false) + }) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index 2f9a62ae7ff375..7b8fe20e3cc2f1 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -1,13 +1,82 @@ 'use client' +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { Button } from '@langgenius/dify-ui/button' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { getNetworkAccessErrorStatus } from '@/app/components/app/access-point/access-control/network-access' +import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + pricingQueryParamName, + pricingQueryParser, +} from '@/app/components/billing/pricing/query-params' +import { consoleQuery } from '@/service/console' import { IpPolicyDialog } from './policy-dialog' +import { PolicyItem } from './policy-item' + +type DialogState = { mode: 'create' } | { mode: 'edit'; group: NetworkAccessGroupResponse } | null + +function IpPoliciesListSkeleton() { + const { t } = useTranslation() + + return ( +
$.loading, { ns: 'common' })} className="space-y-2"> + {Array.from({ length: 2 }, (_, index) => ( +
+ + +
+ + +
+
+
+
+ ))} +
+ ) +} export default function IpPoliciesPage() { const { t } = useTranslation() - const [dialogOpen, setDialogOpen] = useState(false) + const queryClient = useQueryClient() + const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) + const groupsQuery = consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ + retry: false, + }) + const { data, isPending, isError } = useQuery(groupsQuery) + const createGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.post.mutationOptions(), + ) + const updateGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.byGroupId.put.mutationOptions(), + ) + const [dialogState, setDialogState] = useState(null) + const groups = data?.groups ?? [] + const entitled = data?.entitled === true + const editingGroup = dialogState?.mode === 'edit' ? dialogState.group : null + const isSaving = createGroup.isPending || updateGroup.isPending + + const refreshGroupsAfterConflict = async (groupId?: string) => { + const refreshed = await queryClient.fetchQuery(groupsQuery) + if (!groupId) return + const nextGroup = refreshed.groups.find((group) => group.id === groupId) + setDialogState(nextGroup ? { mode: 'edit', group: nextGroup } : null) + } + + const handleOpenCreate = () => { + if (isError) return + if (!entitled) { + void setPricing('open') + return + } + setDialogState({ mode: 'create' }) + } return (
@@ -20,21 +89,92 @@ export default function IpPoliciesPage() { {t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' })}

-
-
-

- {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} -

-

- {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} + {isPending && } + {!isPending && isError && ( +

+ {t(($) => $['common.loadFailed'], { ns: 'deployments' })}

-
+ )} + + {!isPending && !isError && groups.length === 0 && ( +
+

+ {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} +

+

+ {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} +

+
+ )} + + {!isPending && groups.length > 0 && ( +
+ {groups.map((group) => ( + setDialogState({ mode: 'edit', group: nextGroup })} + /> + ))} +
+ )} + + {dialogState && ( + { + if (!open) setDialogState(null) + }} + onSubmit={(payload) => { + if (editingGroup) { + updateGroup.mutate( + { + params: { group_id: editingGroup.id }, + body: { + name: payload.name, + description: editingGroup.description ?? '', + allowed_cidrs: payload.allowed_cidrs, + expected_version: editingGroup.version, + }, + }, + { + onSuccess: () => setDialogState(null), + onError: (error) => { + if (getNetworkAccessErrorStatus(error) !== 409) return + void refreshGroupsAfterConflict(editingGroup.id) + }, + }, + ) + return + } - {dialogOpen && } + createGroup.mutate( + { + body: { + name: payload.name, + description: '', + allowed_cidrs: payload.allowed_cidrs, + }, + }, + { + onSuccess: () => setDialogState(null), + }, + ) + }} + /> + )}
) } diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index 8bacd3a0b721b1..7252f3afd65064 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -1,5 +1,6 @@ 'use client' +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { Button } from '@langgenius/dify-ui/button' import { Dialog, @@ -15,11 +16,16 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { AllowlistField } from './allowlist-field' import { createAllowlistRow } from './allowlist-row' -import { canSubmitIpPolicy, collectIpAddresses } from './validate-ip-entry' +import { PolicyReferencedApps } from './referenced-apps' +import { + canSubmitIpPolicy, + collectAllowedCidrs, + IP_POLICY_NAME_MAX_LENGTH, +} from './validate-ip-entry' export type IpPolicyDialogSubmit = { name: string - addresses: string[] + allowed_cidrs: string[] } type IpPolicyDialogProps = { @@ -28,6 +34,9 @@ type IpPolicyDialogProps = { currentIp?: string initialName?: string initialEntries?: readonly string[] + usedByCount?: number + referencedApps?: NetworkAccessGroupResponse['apps'] + isPending?: boolean onOpenChange: (open: boolean) => void onSubmit?: (payload: IpPolicyDialogSubmit) => void } @@ -38,6 +47,9 @@ export function IpPolicyDialog({ currentIp, initialName = '', initialEntries, + usedByCount = 0, + referencedApps = [], + isPending = false, onOpenChange, onSubmit, }: IpPolicyDialogProps) { @@ -68,6 +80,17 @@ export function IpPolicyDialog({ {t(($) => $['settings.ipPolicyDialogDescription'], { ns: 'common' })} + {mode === 'edit' && usedByCount > 0 && ( +
+

+ {t(($) => $['settings.ipPolicyEditUsedBy'], { + ns: 'common', + count: usedByCount, + })} +

+ +
+ )}
@@ -97,6 +120,7 @@ export function IpPolicyDialog({ {t(($) => $['settings.ipPolicyName'], { ns: 'common' })} $['settings.ipPolicyNamePlaceholder'], { ns: 'common' })} onChange={(event) => setName(event.currentTarget.value)} /> @@ -108,7 +132,12 @@ export function IpPolicyDialog({ - + + + + +
+ + {t(($) => $['settings.ipPolicyDeleteConfirm'], { ns: 'common', name: group.name })} + + + {isBound + ? t(($) => $['settings.ipPolicyDeleteBoundDescription'], { + ns: 'common', + name: group.name, + count: group.used_by_count, + }) + : t(($) => $['settings.ipPolicyDeleteDescription'], { ns: 'common' })} + + {isBound && ( + + )} +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + +
+
+ + ) +} diff --git a/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx b/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx new file mode 100644 index 00000000000000..393fc30ce726e1 --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx @@ -0,0 +1,56 @@ +'use client' + +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { AppIconType } from '@/types/app' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' + +type PolicyReferencedAppsProps = { + apps: NetworkAccessGroupResponse['apps'] + usedByCount: number +} + +function toAppIconType(iconType: string | null | undefined): AppIconType | undefined { + if (iconType === 'image' || iconType === 'emoji' || iconType === 'link') return iconType +} + +export function PolicyReferencedApps({ apps, usedByCount }: PolicyReferencedAppsProps) { + const { t } = useTranslation() + + if (apps.length === 0) { + if (usedByCount <= 0) return null + + return ( +

+ {t(($) => $['settings.ipPolicyUsedBy'], { ns: 'common', count: usedByCount })} +

+ ) + } + + return ( + + ) +} diff --git a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts index c1421076974a7a..fdcc592e01384e 100644 --- a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts +++ b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts @@ -100,13 +100,17 @@ export function validateIpEntry(raw: string): IpEntryValidation { return { kind: 'invalid', code: 'unsupported' } } +export const IP_POLICY_NAME_MAX_LENGTH = 100 +export const IP_POLICY_CIDR_MAX_COUNT = 100 + export function canSubmitIpPolicy(name: string, entries: readonly string[]) { - if (name.trim() === '') return false + const trimmedName = name.trim() + if (trimmedName === '' || trimmedName.length > IP_POLICY_NAME_MAX_LENGTH) return false - const trimmed = entries.map((entry) => entry.trim()) - if (trimmed.every((entry) => entry === '')) return false + const cidrs = collectAllowedCidrs(entries) + if (cidrs.length === 0 || cidrs.length > IP_POLICY_CIDR_MAX_COUNT) return false - return trimmed.every((entry) => { + return entries.every((entry) => { const result = validateIpEntry(entry) return result.kind === 'empty' || result.kind === 'valid' }) @@ -115,3 +119,59 @@ export function canSubmitIpPolicy(name: string, entries: readonly string[]) { export function collectIpAddresses(entries: readonly string[]) { return entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0) } + +export function toAllowedCidr(entry: string): string { + const value = entry.trim() + if (value.includes('/')) return value + if (value.includes(':')) return `${value}/128` + return `${value}/32` +} + +export function collectAllowedCidrs(entries: readonly string[]) { + return collectIpAddresses(entries).map(toAllowedCidr) +} + +export function splitPolicySummary(addresses: readonly string[]): { + listed: readonly string[] + moreCount: number +} { + if (addresses.length <= 2) return { listed: addresses, moreCount: 0 } + return { listed: addresses.slice(0, 2), moreCount: addresses.length - 2 } +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.') + if (parts.length !== 4) return null + + let value = 0 + for (const part of parts) { + if (!/^\d+$/.test(part)) return null + const octet = Number(part) + if (octet > 255) return null + value = (value << 8) + octet + } + + return value >>> 0 +} + +function ipv4InCidr(ip: string, network: string, prefix: number): boolean { + const ipInt = ipv4ToInt(ip) + const networkInt = ipv4ToInt(network) + if (ipInt === null || networkInt === null) return false + if (prefix === 0) return true + const mask = prefix === 32 ? 0xffffffff : ~((1 << (32 - prefix)) - 1) >>> 0 + return (ipInt & mask) === (networkInt & mask) +} + +export function policyIncludesIp(addresses: readonly string[], ip: string): boolean { + return addresses.some((entry) => { + const cidr = toAllowedCidr(entry) + const slash = cidr.indexOf('/') + const network = slash === -1 ? cidr : cidr.slice(0, slash) + const prefix = slash === -1 ? (cidr.includes(':') ? 128 : 32) : Number(cidr.slice(slash + 1)) + if (network.includes(':')) return cidr === ip || network === ip + if (entry === ip || network === ip) return true + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false + return ipv4InCidr(ip, network, prefix) + }) +} diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index eb69b5b0f3d4aa..225a74aece1a25 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "الأعضاء", "settings.permissionSet": "مجموعة الأذونات", "settings.permissionSetDescription": "قم بتكوين مجموعات الأذونات لاستخدامها مع التطبيقات وقواعد المعرفة. مجموعة الأذونات هي مجموعة قابلة لإعادة الاستخدام من أذونات عمليات الموارد يمكن تعيينها للأعضاء لموارد محددة.", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 93e26661f2650d..8b721a7f967370 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Mitglieder", "settings.permissionSet": "Berechtigungssatz", "settings.permissionSetDescription": "Konfigurieren Sie Berechtigungssätze zur Verwendung mit Apps und Wissensdatenbanken. Ein Berechtigungssatz ist eine wiederverwendbare Sammlung von Berechtigungen für Ressourcenoperationen, die Mitgliedern für bestimmte Ressourcen zugewiesen werden kann.", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index a84a4053562ca2..932b712f7bdd81 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Members", "settings.permissionSet": "Permission Set", "settings.permissionSetDescription": "Configure permission sets for use with applications and knowledge bases. A permission set is a reusable collection of resource operation permissions that can be assigned to members for specific resources.", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index dd812a3bd8a207..0ab3427d1a9913 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Miembros", "settings.permissionSet": "Conjunto de permisos", "settings.permissionSetDescription": "Configura conjuntos de permisos para usarlos con aplicaciones y bases de conocimiento. Un conjunto de permisos es una colección reutilizable de permisos de operaciones sobre recursos que se puede asignar a miembros para recursos específicos.", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 55b82ede36f090..2ac2b021d6db73 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "اعضا", "settings.permissionSet": "مجموعه مجوزها", "settings.permissionSetDescription": "مجموعه‌های مجوز را برای استفاده با برنامه‌ها و پایگاه‌های دانش پیکربندی کنید. مجموعه مجوز، مجموعه‌ای قابل استفاده مجدد از مجوزهای عملیات منبع است که می‌توان آن را برای منابع مشخص به اعضا اختصاص داد.", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index b1d03a9d0df354..86d86bfc5df567 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Membres", "settings.permissionSet": "Ensemble d’autorisations", "settings.permissionSetDescription": "Configurez des ensembles d’autorisations à utiliser avec les apps et les bases de connaissances. Un ensemble d’autorisations est une collection réutilisable d’autorisations d’opérations sur les ressources pouvant être attribuée aux membres pour des ressources spécifiques.", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index e2c1ddaef0688d..ef9c34a809d8b2 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "सदस्य", "settings.permissionSet": "अनुमति सेट", "settings.permissionSetDescription": "ऐप्लिकेशन और नॉलेज बेस के साथ उपयोग के लिए अनुमति सेट कॉन्फ़िगर करें। अनुमति सेट संसाधन संचालन अनुमतियों का पुन: उपयोग योग्य संग्रह है, जिसे विशिष्ट संसाधनों के लिए सदस्यों को असाइन किया जा सकता है।", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 3a0597bcd97a35..af066ebe2a3e0f 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Anggota", "settings.permissionSet": "Kumpulan Izin", "settings.permissionSetDescription": "Konfigurasikan kumpulan izin untuk digunakan dengan aplikasi dan basis pengetahuan. Kumpulan izin adalah koleksi izin operasi sumber daya yang dapat digunakan kembali dan dapat ditetapkan kepada anggota untuk sumber daya tertentu.", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 25998000dcac27..8d0e5ebe688f83 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Membri", "settings.permissionSet": "Set di autorizzazioni", "settings.permissionSetDescription": "Configura set di autorizzazioni da usare con app e basi di conoscenza. Un set di autorizzazioni è una raccolta riutilizzabile di autorizzazioni per operazioni sulle risorse che può essere assegnata ai membri per risorse specifiche.", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index c0c24bc0f4cd03..71704aff0594e6 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "追加", "settings.ipPolicyAllowlist": "許可リスト", "settings.ipPolicyAllowlistHelp": "単一のアドレス(203.0.113.42)または CIDR 範囲(10.0.0.0/8)を入力できます。IPv4 と IPv6 の両方に対応しています。", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "作成", "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "アクセスを許可する IP アドレスまたは範囲を指定します。", "settings.ipPolicyEditTitle": "IP ポリシーを編集", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "有効な IPv6 アドレスではありません", "settings.ipPolicyEntryLeadingZeros": "先頭のゼロは使用できません", "settings.ipPolicyEntryMultipleSlashes": "「/」は 1 つだけ使用できます", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "プレフィックス長は 0〜{{max}} の範囲で指定してください", "settings.ipPolicyEntryUnsupported": "IPv4 / IPv6 アドレスと CIDR 範囲のみサポートしています", "settings.ipPolicyName": "名前", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "例:社内ネットワーク", "settings.ipPolicyNewTitle": "IP ポリシーを作成", "settings.ipPolicyRemoveEntry": "エントリを削除", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "メンバー", "settings.permissionSet": "権限セット", "settings.permissionSetDescription": "アプリとナレッジベースで使用する権限セットを設定します。権限セットは、特定のリソースに対してメンバーに割り当てられる、リソース操作権限の再利用可能なコレクションです。", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 6a9d56f621fc1b..9e7abc4a677004 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "멤버", "settings.permissionSet": "권한 세트", "settings.permissionSetDescription": "앱과 지식 베이스에서 사용할 권한 세트를 구성합니다. 권한 세트는 특정 리소스에 대해 멤버에게 할당할 수 있는 재사용 가능한 리소스 작업 권한 모음입니다.", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index e85c7682040893..f9e03d739aebf3 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "ສະມາຊິກ", "settings.permissionSet": "ຊຸດການອະນຸຍາດ", "settings.permissionSetDescription": "ຕັ້ງຄ່າຊຸດການອະນຸຍາດສຳລັບໃຊ້ກັບແອັບ ແລະ ຖານຄວາມຮູ້. ຊຸດການອະນຸຍາດແມ່ນການລວມເອົາສິດການດຳເນີນການຊັບພະຍາກອນທີ່ສາມາດນຳມາໃຊ້ຄືນໄດ້ ເຊິ່ງສາມາດມອບໝາຍໃຫ້ສະມາຊິກສຳລັບຊັບພະຍາກອນສະເພາະ.", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index ddfa6f7a06b1d7..cfd81d2f6e46ee 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Members", "settings.permissionSet": "Set met rechten", "settings.permissionSetDescription": "Configureer sets met rechten voor gebruik met apps en kennisbanken. Een set met rechten is een herbruikbare verzameling rechten voor resourcebewerkingen die aan leden kan worden toegewezen voor specifieke resources.", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 0a3cefee6ab174..7822b7536fd23e 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Członkowie", "settings.permissionSet": "Zestaw uprawnień", "settings.permissionSetDescription": "Skonfiguruj zestawy uprawnień do użycia z aplikacjami i bazami wiedzy. Zestaw uprawnień to wielokrotnego użytku zbiór uprawnień do operacji na zasobach, który można przypisywać członkom dla konkretnych zasobów.", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 48605759e59191..e973dbef156761 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Membros", "settings.permissionSet": "Conjunto de permissões", "settings.permissionSetDescription": "Configure conjuntos de permissões para uso com apps e bases de conhecimento. Um conjunto de permissões é uma coleção reutilizável de permissões de operações de recursos que pode ser atribuída a membros para recursos específicos.", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 8ee85634fb2d04..c35dd659b59a66 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Membri", "settings.permissionSet": "Set de permisiuni", "settings.permissionSetDescription": "Configurați seturi de permisiuni pentru utilizare cu aplicații și baze de cunoștințe. Un set de permisiuni este o colecție reutilizabilă de permisiuni pentru operațiuni asupra resurselor, care poate fi atribuită membrilor pentru resurse specifice.", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 1c67818d8dcbf4..0620d6b6d2822d 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Участники", "settings.permissionSet": "Набор разрешений", "settings.permissionSetDescription": "Настройте наборы разрешений для использования с приложениями и базами знаний. Набор разрешений — это многократно используемая коллекция разрешений на операции с ресурсами, которую можно назначать участникам для конкретных ресурсов.", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index c7cd538bde3077..c16b0e8a1571ee 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Člani", "settings.permissionSet": "Nabor dovoljenj", "settings.permissionSetDescription": "Konfigurirajte nabore dovoljenj za uporabo z aplikacijami in bazami znanja. Nabor dovoljenj je ponovno uporabna zbirka dovoljenj za operacije nad viri, ki jo je mogoče dodeliti članom za določene vire.", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index fab0bd659d810e..93ac7b9fd6e2fd 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "สมาชิก", "settings.permissionSet": "ชุดสิทธิ์", "settings.permissionSetDescription": "กำหนดค่าชุดสิทธิ์สำหรับใช้กับแอปและฐานความรู้ ชุดสิทธิ์คือชุดสิทธิ์การดำเนินการกับทรัพยากรที่นำกลับมาใช้ซ้ำได้ และสามารถกำหนดให้สมาชิกสำหรับทรัพยากรเฉพาะได้", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 915ea0f93b926c..f475cd6d6f85e7 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Üyeler", "settings.permissionSet": "İzin Seti", "settings.permissionSetDescription": "Uygulamalar ve bilgi tabanlarıyla kullanılacak izin setlerini yapılandırın. İzin seti, belirli kaynaklar için üyelere atanabilen yeniden kullanılabilir kaynak işlem izinleri koleksiyonudur.", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index dcc10ec7e33a02..a43b6addf0a876 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Учасники", "settings.permissionSet": "Набір дозволів", "settings.permissionSetDescription": "Налаштуйте набори дозволів для використання із застосунками та базами знань. Набір дозволів — це багаторазова колекція дозволів на операції з ресурсами, яку можна призначати учасникам для конкретних ресурсів.", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 443789e55d5569..9e1fdfe365c4c5 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "Add", "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", + "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyDeleteBound": "This policy is in use", + "settings.ipPolicyDeleteBoundDescription": "{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This cannot be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", + "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", "settings.ipPolicyName": "Name", + "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", + "settings.ipPolicyUsedBy": "Used by {{count}} apps", "settings.members": "Thành viên", "settings.permissionSet": "Bộ quyền", "settings.permissionSetDescription": "Định cấu hình các bộ quyền để sử dụng với ứng dụng và cơ sở kiến thức. Bộ quyền là tập hợp có thể tái sử dụng các quyền thao tác trên tài nguyên, có thể được gán cho thành viên đối với các tài nguyên cụ thể.", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 722123cc88519f..541985f5d38aa3 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "添加", "settings.ipPolicyAllowlist": "允许列表", "settings.ipPolicyAllowlistHelp": "支持单个地址(203.0.113.42)或 CIDR 网段(10.0.0.0/8),IPv4 与 IPv6 均可。", + "settings.ipPolicyCidrMax": "一条策略最多包含 {{max}} 个地址", "settings.ipPolicyCreate": "创建", "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", + "settings.ipPolicyDeleteBound": "该策略正在使用中", + "settings.ipPolicyDeleteBoundDescription": "{{name}} 已应用到 {{count}} 个应用。删除后这些应用将不再受 IP 限制,可从任意 IP 访问。其他登录与应用权限不变。", + "settings.ipPolicyDeleteConfirm": "删除“{{name}}”?", + "settings.ipPolicyDeleteDescription": "此操作无法撤销。", "settings.ipPolicyDialogDescription": "指定允许访问的 IP 地址或网段。", "settings.ipPolicyEditTitle": "编辑 IP 策略", + "settings.ipPolicyEditUsedBy": "该策略已被 {{count}} 个应用使用。保存后会更新所有这些应用的允许列表。", "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 地址", "settings.ipPolicyEntryLeadingZeros": "不允许前导零", "settings.ipPolicyEntryMultipleSlashes": "只能包含一个“/”", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "前缀长度必须在 0–{{max}} 之间", "settings.ipPolicyEntryUnsupported": "仅支持 IPv4 / IPv6 地址和 CIDR 网段", "settings.ipPolicyName": "名称", + "settings.ipPolicyNameMax": "名称不能超过 {{max}} 个字符", "settings.ipPolicyNamePlaceholder": "例:内部网络", "settings.ipPolicyNewTitle": "新建 IP 策略", "settings.ipPolicyRemoveEntry": "删除条目", + "settings.ipPolicyUsedBy": "已被 {{count}} 个应用使用", "settings.members": "成员", "settings.permissionSet": "权限集", "settings.permissionSetDescription": "配置可供应用和知识库使用的权限集。权限集是一组可复用的资源操作权限,可针对特定资源分配给成员。", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 0b68172b5f9851..14765e508930d3 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -563,10 +563,16 @@ "settings.ipPolicyAddEntry": "新增", "settings.ipPolicyAllowlist": "允許清單", "settings.ipPolicyAllowlistHelp": "支援單一地址(203.0.113.42)或 CIDR 網段(10.0.0.0/8),IPv4 與 IPv6 皆可。", + "settings.ipPolicyCidrMax": "一條策略最多包含 {{max}} 個地址", "settings.ipPolicyCreate": "建立", "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", + "settings.ipPolicyDeleteBound": "該策略正在使用中", + "settings.ipPolicyDeleteBoundDescription": "{{name}} 已套用到 {{count}} 個應用。刪除後這些應用將不再受 IP 限制,可從任意 IP 存取。其他登入與應用權限不變。", + "settings.ipPolicyDeleteConfirm": "刪除「{{name}}」?", + "settings.ipPolicyDeleteDescription": "此操作無法復原。", "settings.ipPolicyDialogDescription": "指定允許存取的 IP 位址或網段。", "settings.ipPolicyEditTitle": "編輯 IP 策略", + "settings.ipPolicyEditUsedBy": "該策略已被 {{count}} 個應用使用。儲存後會更新所有這些應用的允許清單。", "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 位址", "settings.ipPolicyEntryLeadingZeros": "不允許前導零", "settings.ipPolicyEntryMultipleSlashes": "只能包含一個“/”", @@ -575,9 +581,11 @@ "settings.ipPolicyEntryPrefixRange": "前置碼長度必須在 0–{{max}} 之間", "settings.ipPolicyEntryUnsupported": "僅支援 IPv4 / IPv6 位址和 CIDR 網段", "settings.ipPolicyName": "名稱", + "settings.ipPolicyNameMax": "名稱不能超過 {{max}} 個字元", "settings.ipPolicyNamePlaceholder": "例:內部網路", "settings.ipPolicyNewTitle": "新增 IP 策略", "settings.ipPolicyRemoveEntry": "刪除條目", + "settings.ipPolicyUsedBy": "已被 {{count}} 個應用使用", "settings.members": "成員", "settings.permissionSet": "權限集", "settings.permissionSetDescription": "配置可供應用和知識庫使用的權限集。權限集是一組可重複使用的資源操作權限,可針對特定資源分配給成員。", diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 48f35dc0e72613..35b41dd1bb06c9 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -15,6 +15,34 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { experimental_defaults: { workspaces: { current: { + networkAccessGroups: { + post: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => { + if (error) return + return invalidateNetworkAccessGroupQueries(consoleQuery, context.client) + }, + }, + }, + byGroupId: { + put: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => { + if (error) return + return invalidateNetworkAccessGroupQueries(consoleQuery, context.client) + }, + }, + }, + delete: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => { + if (error) return + return invalidateNetworkAccessGroupQueries(consoleQuery, context.client) + }, + }, + }, + }, + }, rbac: { accessPolicies: { post: { @@ -208,6 +236,16 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { }, byAppId: { // Shared invalidation uses onSettled so feature-owned onSuccess callbacks can coexist. + networkAccessGroup: { + put: { + mutationOptions: { + onSettled: (_data, error, _variables, _result, context) => { + if (error) return + return invalidateNetworkAccessGroupQueries(consoleQuery, context.client) + }, + }, + }, + }, apiEnable: { post: { mutationOptions: { @@ -1242,6 +1280,17 @@ function invalidateQueryKeys(client: QueryClient, queryKeys: QueryKey[]) { return Promise.all(queryKeys.map((queryKey) => client.invalidateQueries({ queryKey }))) } +function invalidateNetworkAccessGroupQueries( + query: RouterUtils, + client: QueryClient, +) { + return invalidateQueryKeys(client, [ + query.workspaces.current.networkAccessGroups.get.key(), + query.workspaces.current.networkAccessGroups.byGroupId.key(), + query.apps.byAppId.networkAccessGroup.key(), + ]) +} + function invalidateAccessPolicyQueries(query: RouterUtils, client: QueryClient) { return invalidateQueryKeys(client, [ query.workspaces.current.rbac.key(), diff --git a/web/test/console/network-access.ts b/web/test/console/network-access.ts new file mode 100644 index 00000000000000..3c125e0c0eaed3 --- /dev/null +++ b/web/test/console/network-access.ts @@ -0,0 +1,66 @@ +import type { AppNetworkAccessGroupResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + NetworkAccessGroupListResponse, + NetworkAccessGroupResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { QueryClient } from '@tanstack/react-query' +import { consoleQuery } from '@/service/console' + +export function createNetworkAccessGroupFixture( + overrides: Partial = {}, +): NetworkAccessGroupResponse { + return { + id: 'group-1', + tenant_id: 'workspace-1', + name: 'Internal Network', + description: '', + allowed_cidrs: ['203.0.113.42/32', '198.51.100.0/24'], + app_ids: [], + apps: [], + used_by_count: 0, + version: 1, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + updated_by_account_id: null, + ...overrides, + } +} + +export function seedNetworkAccessGroups( + queryClient: QueryClient, + overrides: Partial = {}, +): NetworkAccessGroupListResponse { + const data: NetworkAccessGroupListResponse = { + tenant_id: 'workspace-1', + entitled: true, + groups: [], + ...overrides, + } + queryClient.setQueryData( + consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions().queryKey, + data as never, + ) + return data +} + +export function seedAppNetworkAccessGroup( + queryClient: QueryClient, + appId: string, + overrides: Partial = {}, +): AppNetworkAccessGroupResponse { + const data: AppNetworkAccessGroupResponse = { + tenant_id: 'workspace-1', + app_id: appId, + entitled: true, + available_access_points: ['webapp', 'service_api', 'mcp'], + binding: null, + ...overrides, + } + queryClient.setQueryData( + consoleQuery.apps.byAppId.networkAccessGroup.get.queryOptions({ + input: { params: { app_id: appId } }, + }).queryKey, + data, + ) + return data +} From 225a04c2bf7c8e9bed349823ce54571de4b05b83 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 10 Sep 2026 18:02:58 +0800 Subject: [PATCH 022/108] test(web): cover IP Policy create posting to network access groups The create dialog used to swallow submit when onSubmit was omitted. Keep a page-level assertion that Create sends the console POST. --- .../ip-policies-page/__tests__/index.spec.tsx | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index 8494d37e601843..a06dfc8f3070cb 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { @@ -63,6 +63,52 @@ describe('IpPoliciesPage', () => { expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() }) + it('posts a new policy when Create is clicked', async () => { + const user = userEvent.setup() + const created = createNetworkAccessGroupFixture({ name: 'Office' }) + const posted: Array<{ method: string; url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + const bodyText = + request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text() + posted.push({ + method: request.method, + url: request.url, + body: bodyText ? JSON.parse(bodyText) : null, + }) + return new Response(JSON.stringify({ group: created, entitled: true }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }) + }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true, groups: [] }) + renderWithConsoleQuery( + + + , + { queryClient }, + ) + + await user.click(screen.getByRole('button', { name: 'Add' })) + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Office') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(posted).toContainEqual({ + method: 'POST', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups', + body: { + name: 'Office', + description: '', + allowed_cidrs: ['10.0.0.0/8'], + }, + }) + }) + }) + it('lists existing policies and opens edit from the row', async () => { const user = userEvent.setup() const queryClient = createConsoleQueryClient() From d9a90756362d51e3f83dbfc00b60693b2ad18d2d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 10 Sep 2026 18:36:10 +0800 Subject: [PATCH 023/108] fix(web): match IP Policy Used by chips to Figma Move Used by into the edit dialog body as a labeled field of app chips with icon, name, and an external-link arrow. --- .../ip-policies-page/__tests__/index.spec.tsx | 10 +++------ .../ip-policies-page/policy-dialog.tsx | 22 +++++++++---------- .../ip-policies-page/referenced-apps.tsx | 16 ++++++++------ web/i18n/ar-TN/common.json | 1 + web/i18n/de-DE/common.json | 1 + web/i18n/en-US/common.json | 1 + web/i18n/es-ES/common.json | 1 + web/i18n/fa-IR/common.json | 1 + web/i18n/fr-FR/common.json | 1 + web/i18n/hi-IN/common.json | 1 + web/i18n/id-ID/common.json | 1 + web/i18n/it-IT/common.json | 1 + web/i18n/ja-JP/common.json | 1 + web/i18n/ko-KR/common.json | 1 + web/i18n/lo-LA/common.json | 1 + web/i18n/nl-NL/common.json | 1 + web/i18n/pl-PL/common.json | 1 + web/i18n/pt-BR/common.json | 1 + web/i18n/ro-RO/common.json | 1 + web/i18n/ru-RU/common.json | 1 + web/i18n/sl-SI/common.json | 1 + web/i18n/th-TH/common.json | 1 + web/i18n/tr-TR/common.json | 1 + web/i18n/uk-UA/common.json | 1 + web/i18n/vi-VN/common.json | 1 + web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hant/common.json | 1 + 27 files changed, 46 insertions(+), 26 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index a06dfc8f3070cb..1582334b77f844 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -25,8 +25,7 @@ const translations = vi.hoisted(() => ({ 'settings.ipPolicyDeleteBoundDescription': '{{name}} is applied to {{count}} apps. Deleting it turns off IP restriction for those apps, so they can be reached from any IP. Other app permissions stay the same.', 'settings.ipPolicyDeleteConfirm': 'Delete “{{name}}”?', - 'settings.ipPolicyEditUsedBy': - 'This policy is used by {{count}} apps. Saving updates the allowlist for all of them.', + 'settings.ipPolicyUsedByLabel': 'Used by', 'settings.ipPolicyDialogDescription': 'Specify which IP addresses or ranges can access your apps.', 'settings.ipPolicyEditTitle': 'Edit IP Policy', @@ -159,15 +158,12 @@ describe('IpPoliciesPage', () => { ) await user.click(screen.getByRole('button', { name: 'Edit' })) - expect( - screen.getByText( - 'This policy is used by 2 apps. Saving updates the allowlist for all of them.', - ), - ).toBeInTheDocument() + expect(screen.getByText('Used by')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( 'href', '/app/app-a/overview', ) + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute('target', '_blank') }) it('lets the user delete a referenced policy after confirming the impact', async () => { diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index 7252f3afd65064..ab794d9cfb01ff 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -80,17 +80,6 @@ export function IpPolicyDialog({ {t(($) => $['settings.ipPolicyDialogDescription'], { ns: 'common' })} - {mode === 'edit' && usedByCount > 0 && ( -
-

- {t(($) => $['settings.ipPolicyEditUsedBy'], { - ns: 'common', - count: usedByCount, - })} -

- -
- )} { event.preventDefault() if (!canSubmit) return @@ -128,6 +117,15 @@ export function IpPolicyDialog({ + {mode === 'edit' && usedByCount > 0 && ( + + + {t(($) => $['settings.ipPolicyUsedByLabel'], { ns: 'common' })} + + + + )} +
@@ -113,7 +120,24 @@ export default function IpPoliciesPage() { )} {!isPending && groups.length > 0 && ( -
+
+
+
+ {t(($) => $['settings.ipPolicyColumnName'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnIpEntries'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnEnforcing'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnUpdatedAt'], { ns: 'common' })} +
+
+
{groups.map((group) => ( 0 - const usedByNames = group.apps.map((app) => app.name).filter(Boolean) + const enforcingLabel = + group.used_by_count <= 0 + ? t(($) => $['settings.ipPolicyEnforcingNone'], { ns: 'common' }) + : group.used_by_count === 1 + ? t(($) => $['settings.ipPolicyEnforcingOne'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyEnforcingMany'], { + ns: 'common', + count: group.used_by_count, + }) const handleDelete = () => { if (!canMutate) return @@ -57,64 +74,61 @@ export function PolicyItem({ group, canMutate, onEdit }: PolicyItemProps) { } return ( -
-
-

{group.name}

-

- {summary.listed.length === 0 - ? t(($) => $['studio.accessControl.policySummaryNone'], { ns: 'deployments' }) - : summary.listed.length === 1 - ? t(($) => $['studio.accessControl.policySummaryOne'], { - ns: 'deployments', - address: summary.listed[0], - }) - : summary.moreCount > 0 - ? t(($) => $['studio.accessControl.policySummaryMany'], { - ns: 'deployments', - listed: summary.listed.join(', '), - count: summary.moreCount, - }) - : t(($) => $['studio.accessControl.policySummaryTwo'], { - ns: 'deployments', - first: summary.listed[0], - second: summary.listed[1], - })} -

- {isBound && ( -
-
- {group.apps.slice(0, 3).map((app) => ( - - ))} -
-

- {usedByNames.length > 0 - ? usedByNames.join(', ') - : t(($) => $['settings.ipPolicyUsedBy'], { - ns: 'common', - count: group.used_by_count, - })} -

-
- )} -
-
- - +
+

+ {group.name} +

+

+ {group.allowed_cidrs.length} +

+

+ {enforcingLabel} +

+

+ {formatTimeFromNow(Date.parse(group.updated_at))} +

+
+ + $['operation.moreActionsFor'], { + ns: 'common', + name: group.name, + })} + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + + { + if (!canMutate) return + onEdit(group) + }} + > + {t(($) => $['operation.edit'], { ns: 'common' })} + + + { + if (!canMutate) return + setConfirmDelete(true) + setMenuOpen(false) + }} + > + {t(($) => $['operation.delete'], { ns: 'common' })} + + +
diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index 179e74268ee9bb..6dedf5fed9b3eb 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "ساري", + "settings.ipPolicyColumnIpEntries": "إدخالات IP", + "settings.ipPolicyColumnName": "الاسم", + "settings.ipPolicyColumnUpdatedAt": "آخر تحديث", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} تطبيقات", + "settings.ipPolicyEnforcingNone": "0 تطبيقات", + "settings.ipPolicyEnforcingOne": "تطبيق واحد", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index ffee75e285adcd..1dc71f753c4e9c 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Aktiv", + "settings.ipPolicyColumnIpEntries": "IP-Einträge", + "settings.ipPolicyColumnName": "Name", + "settings.ipPolicyColumnUpdatedAt": "Aktualisiert", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} Apps", + "settings.ipPolicyEnforcingNone": "0 Apps", + "settings.ipPolicyEnforcingOne": "1 App", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 0e437023f1c4e8..449aa9927f0b6c 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Enforcing", + "settings.ipPolicyColumnIpEntries": "IP entries", + "settings.ipPolicyColumnName": "Name", + "settings.ipPolicyColumnUpdatedAt": "Updated at", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} apps", + "settings.ipPolicyEnforcingNone": "0 apps", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index e7c83ff0400110..62472fd194b3d4 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "En vigor", + "settings.ipPolicyColumnIpEntries": "Entradas IP", + "settings.ipPolicyColumnName": "Nombre", + "settings.ipPolicyColumnUpdatedAt": "Actualizado", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} apps", + "settings.ipPolicyEnforcingNone": "0 apps", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 021c24ea8728bf..da7113012485bf 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "در حال اعمال", + "settings.ipPolicyColumnIpEntries": "ورودی‌های IP", + "settings.ipPolicyColumnName": "نام", + "settings.ipPolicyColumnUpdatedAt": "به‌روزرسانی", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} برنامه", + "settings.ipPolicyEnforcingNone": "۰ برنامه", + "settings.ipPolicyEnforcingOne": "۱ برنامه", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 7d965129040f50..e4d1e07f4958b7 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "En vigueur", + "settings.ipPolicyColumnIpEntries": "Entrées IP", + "settings.ipPolicyColumnName": "Nom", + "settings.ipPolicyColumnUpdatedAt": "Mis à jour", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} apps", + "settings.ipPolicyEnforcingNone": "0 app", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index cfa064d2f9885f..b9293da3e8a2f2 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "लागू", + "settings.ipPolicyColumnIpEntries": "IP प्रविष्टियाँ", + "settings.ipPolicyColumnName": "नाम", + "settings.ipPolicyColumnUpdatedAt": "अपडेट किया गया", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} ऐप", + "settings.ipPolicyEnforcingNone": "0 ऐप", + "settings.ipPolicyEnforcingOne": "1 ऐप", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 0fdd18650d8973..9cc3eaeee7c142 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Berlaku", + "settings.ipPolicyColumnIpEntries": "Entri IP", + "settings.ipPolicyColumnName": "Nama", + "settings.ipPolicyColumnUpdatedAt": "Diperbarui", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} aplikasi", + "settings.ipPolicyEnforcingNone": "0 aplikasi", + "settings.ipPolicyEnforcingOne": "1 aplikasi", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 0c94e9cb72e0d6..46de63d041c247 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "In vigore", + "settings.ipPolicyColumnIpEntries": "Voci IP", + "settings.ipPolicyColumnName": "Nome", + "settings.ipPolicyColumnUpdatedAt": "Aggiornato", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} app", + "settings.ipPolicyEnforcingNone": "0 app", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 20a6d2709fc9c7..7117cf812ee652 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "許可リスト", "settings.ipPolicyAllowlistHelp": "単一のアドレス(203.0.113.42)または CIDR 範囲(10.0.0.0/8)を入力できます。IPv4 と IPv6 の両方に対応しています。", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "適用中", + "settings.ipPolicyColumnIpEntries": "IP エントリ", + "settings.ipPolicyColumnName": "名前", + "settings.ipPolicyColumnUpdatedAt": "更新日時", "settings.ipPolicyCreate": "作成", "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "アクセスを許可する IP アドレスまたは範囲を指定します。", "settings.ipPolicyEditTitle": "IP ポリシーを編集", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} 件のアプリ", + "settings.ipPolicyEnforcingNone": "0 件のアプリ", + "settings.ipPolicyEnforcingOne": "1 件のアプリ", "settings.ipPolicyEntryInvalidIpv6": "有効な IPv6 アドレスではありません", "settings.ipPolicyEntryLeadingZeros": "先頭のゼロは使用できません", "settings.ipPolicyEntryMultipleSlashes": "「/」は 1 つだけ使用できます", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index c48653de962ba9..52cc46289c1213 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "적용 중", + "settings.ipPolicyColumnIpEntries": "IP 항목", + "settings.ipPolicyColumnName": "이름", + "settings.ipPolicyColumnUpdatedAt": "업데이트", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "앱 {{count}}개", + "settings.ipPolicyEnforcingNone": "앱 0개", + "settings.ipPolicyEnforcingOne": "앱 1개", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index 76d146a8cbbb78..aee97f3f3c6ce9 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "ກຳລັງນຳໃຊ້", + "settings.ipPolicyColumnIpEntries": "ລາຍການ IP", + "settings.ipPolicyColumnName": "ຊື່", + "settings.ipPolicyColumnUpdatedAt": "ອັບເດດ", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} ແອັບ", + "settings.ipPolicyEnforcingNone": "0 ແອັບ", + "settings.ipPolicyEnforcingOne": "1 ແອັບ", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 5a15050ab6ad58..7fa4620816be8b 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Actief", + "settings.ipPolicyColumnIpEntries": "IP-vermeldingen", + "settings.ipPolicyColumnName": "Naam", + "settings.ipPolicyColumnUpdatedAt": "Bijgewerkt", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} apps", + "settings.ipPolicyEnforcingNone": "0 apps", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 5228d7ff9f2a0e..b54b7a7616bb8a 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Wymuszane", + "settings.ipPolicyColumnIpEntries": "Wpisy IP", + "settings.ipPolicyColumnName": "Nazwa", + "settings.ipPolicyColumnUpdatedAt": "Zaktualizowano", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} aplikacji", + "settings.ipPolicyEnforcingNone": "0 aplikacji", + "settings.ipPolicyEnforcingOne": "1 aplikacja", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index ad96f2a9417cc6..4b511f33fcb670 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Em vigor", + "settings.ipPolicyColumnIpEntries": "Entradas de IP", + "settings.ipPolicyColumnName": "Nome", + "settings.ipPolicyColumnUpdatedAt": "Atualizado em", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} apps", + "settings.ipPolicyEnforcingNone": "0 apps", + "settings.ipPolicyEnforcingOne": "1 app", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 6a2e05f37f5ac6..f19b579cfe36c6 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "În vigoare", + "settings.ipPolicyColumnIpEntries": "Intrări IP", + "settings.ipPolicyColumnName": "Nume", + "settings.ipPolicyColumnUpdatedAt": "Actualizat", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} aplicații", + "settings.ipPolicyEnforcingNone": "0 aplicații", + "settings.ipPolicyEnforcingOne": "1 aplicație", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 634a86e1f2c813..c5273e3d403448 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Применяется", + "settings.ipPolicyColumnIpEntries": "IP-записи", + "settings.ipPolicyColumnName": "Название", + "settings.ipPolicyColumnUpdatedAt": "Обновлено", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} приложений", + "settings.ipPolicyEnforcingNone": "0 приложений", + "settings.ipPolicyEnforcingOne": "1 приложение", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 782488ba15ac74..3b39912ceb2e55 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "V veljavi", + "settings.ipPolicyColumnIpEntries": "IP vnosi", + "settings.ipPolicyColumnName": "Ime", + "settings.ipPolicyColumnUpdatedAt": "Posodobljeno", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} aplikacij", + "settings.ipPolicyEnforcingNone": "0 aplikacij", + "settings.ipPolicyEnforcingOne": "1 aplikacija", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index f5285c94745a50..7a169f5a2bb012 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "กำลังบังคับใช้", + "settings.ipPolicyColumnIpEntries": "รายการ IP", + "settings.ipPolicyColumnName": "ชื่อ", + "settings.ipPolicyColumnUpdatedAt": "อัปเดตเมื่อ", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} แอป", + "settings.ipPolicyEnforcingNone": "0 แอป", + "settings.ipPolicyEnforcingOne": "1 แอป", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 8380b77fe8e0d7..7bebb32a8458ad 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Uygulanıyor", + "settings.ipPolicyColumnIpEntries": "IP kayıtları", + "settings.ipPolicyColumnName": "Ad", + "settings.ipPolicyColumnUpdatedAt": "Güncellendi", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} uygulama", + "settings.ipPolicyEnforcingNone": "0 uygulama", + "settings.ipPolicyEnforcingOne": "1 uygulama", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index b91aee1673e3b1..7c8fa144e44912 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Застосовується", + "settings.ipPolicyColumnIpEntries": "IP-записи", + "settings.ipPolicyColumnName": "Назва", + "settings.ipPolicyColumnUpdatedAt": "Оновлено", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} застосунків", + "settings.ipPolicyEnforcingNone": "0 застосунків", + "settings.ipPolicyEnforcingOne": "1 застосунок", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 513fac82cd04c8..a6e00910968026 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", + "settings.ipPolicyColumnEnforcing": "Đang áp dụng", + "settings.ipPolicyColumnIpEntries": "Mục IP", + "settings.ipPolicyColumnName": "Tên", + "settings.ipPolicyColumnUpdatedAt": "Cập nhật", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEnforcingMany": "{{count}} ứng dụng", + "settings.ipPolicyEnforcingNone": "0 ứng dụng", + "settings.ipPolicyEnforcingOne": "1 ứng dụng", "settings.ipPolicyEntryInvalidIpv6": "Not a valid IPv6 address", "settings.ipPolicyEntryLeadingZeros": "Leading zeros are not allowed", "settings.ipPolicyEntryMultipleSlashes": "Only one “/” is allowed", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 6945458b49a9a6..0496196501e43f 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "允许列表", "settings.ipPolicyAllowlistHelp": "支持单个地址(203.0.113.42)或 CIDR 网段(10.0.0.0/8),IPv4 与 IPv6 均可。", "settings.ipPolicyCidrMax": "一条策略最多包含 {{max}} 个地址", + "settings.ipPolicyColumnEnforcing": "生效中", + "settings.ipPolicyColumnIpEntries": "IP 条目", + "settings.ipPolicyColumnName": "名称", + "settings.ipPolicyColumnUpdatedAt": "更新时间", "settings.ipPolicyCreate": "创建", "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "该策略正在使用中", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "指定允许访问的 IP 地址或网段。", "settings.ipPolicyEditTitle": "编辑 IP 策略", "settings.ipPolicyEditUsedBy": "该策略已被 {{count}} 个应用使用。保存后会更新所有这些应用的允许列表。", + "settings.ipPolicyEnforcingMany": "{{count}} 个应用", + "settings.ipPolicyEnforcingNone": "0 个应用", + "settings.ipPolicyEnforcingOne": "1 个应用", "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 地址", "settings.ipPolicyEntryLeadingZeros": "不允许前导零", "settings.ipPolicyEntryMultipleSlashes": "只能包含一个“/”", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 133df2a28e18a0..cf77466fd3ac69 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -564,6 +564,10 @@ "settings.ipPolicyAllowlist": "允許清單", "settings.ipPolicyAllowlistHelp": "支援單一地址(203.0.113.42)或 CIDR 網段(10.0.0.0/8),IPv4 與 IPv6 皆可。", "settings.ipPolicyCidrMax": "一條策略最多包含 {{max}} 個地址", + "settings.ipPolicyColumnEnforcing": "生效中", + "settings.ipPolicyColumnIpEntries": "IP 項目", + "settings.ipPolicyColumnName": "名稱", + "settings.ipPolicyColumnUpdatedAt": "更新時間", "settings.ipPolicyCreate": "建立", "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "該策略正在使用中", @@ -573,6 +577,9 @@ "settings.ipPolicyDialogDescription": "指定允許存取的 IP 位址或網段。", "settings.ipPolicyEditTitle": "編輯 IP 策略", "settings.ipPolicyEditUsedBy": "該策略已被 {{count}} 個應用使用。儲存後會更新所有這些應用的允許清單。", + "settings.ipPolicyEnforcingMany": "{{count}} 個應用", + "settings.ipPolicyEnforcingNone": "0 個應用", + "settings.ipPolicyEnforcingOne": "1 個應用", "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 位址", "settings.ipPolicyEntryLeadingZeros": "不允許前導零", "settings.ipPolicyEntryMultipleSlashes": "只能包含一個“/”", From 9e985c21120dc54988e1203f647cffb81b236fb0 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 11 Sep 2026 17:01:55 +0800 Subject: [PATCH 025/108] fix: complete network access control backend contracts --- api/.env.example | 7 + api/configs/feature/__init__.py | 7 + .../console/workspace/network_access_group.py | 62 ++- api/core/network_access/__init__.py | 1 + api/core/network_access/client_ip.py | 95 ++++ api/openapi/markdown/console-openapi.md | 26 ++ .../network_access_group_repository.py | 65 ++- api/services/network_access_group_service.py | 144 +++++- .../workspace/test_network_access_group.py | 108 ++++- .../core/network_access/test_client_ip.py | 108 +++++ .../test_network_access_group_repository.py | 81 +++- .../test_network_access_group_service.py | 306 ++++++++++++- docker/envs/core-services/api.env.example | 5 + .../generated/api/console/apps/types.gen.ts | 2 + .../generated/api/console/apps/zod.gen.ts | 2 + .../api/console/workspaces/orpc.gen.ts | 414 +++++++++--------- .../api/console/workspaces/types.gen.ts | 25 ++ .../api/console/workspaces/zod.gen.ts | 22 + .../ip-policies-page/__tests__/index.spec.tsx | 20 +- web/test/console/network-access.ts | 2 + 20 files changed, 1272 insertions(+), 230 deletions(-) create mode 100644 api/core/network_access/__init__.py create mode 100644 api/core/network_access/client_ip.py create mode 100644 api/tests/unit_tests/core/network_access/test_client_ip.py diff --git a/api/.env.example b/api/.env.example index 0bf7b9493ac542..6643de8f9acfaa 100644 --- a/api/.env.example +++ b/api/.env.example @@ -11,6 +11,13 @@ PYTHONIOENCODING=utf-8 # Console API base URL CONSOLE_API_URL=http://localhost:5001 + +# Optional internal SaaS policy API; defaults to BILLING_API_URL when empty. +NETWORK_ACCESS_API_URL= +# Current-IP preview only. Comma-separated CIDRs, e.g. 172.18.0.0/16. +# Required for Cloud IP previews. Match Gateway's proxy boundary; never trust caller +# headers unless the edge sanitizes XFF or appends its verified socket peer. +NETWORK_ACCESS_TRUSTED_PROXY_CIDRS= CONSOLE_WEB_URL=http://localhost:3000 # Service API base URL diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index aea9e7fcb4b6e3..2b2ca69b27ac22 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -416,6 +416,13 @@ class EndpointConfig(BaseSettings): default="", ) + NETWORK_ACCESS_TRUSTED_PROXY_CIDRS: str = Field( + description="Comma-separated trusted proxy CIDRs for network-access current-IP previews. " + "Only verified socket peers may supply X-Forwarded-For; empty disables Cloud IP previews. " + "Match the Gateway trust boundary and sanitize or append the client IP at the edge.", + default="", + ) + class FileAccessConfig(BaseSettings): """ diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py index 6d5ba151c52614..19204fa0105adf 100644 --- a/api/controllers/console/workspace/network_access_group.py +++ b/api/controllers/console/workspace/network_access_group.py @@ -2,15 +2,18 @@ from typing import Any, Self from uuid import UUID +from flask import Response, after_this_request, request from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, ValidationError, model_validator from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable +from configs import dify_config from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import AppNotFoundError from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import cloud_edition_billing_paid_plan_required, model_validate +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError, resolve_network_access_client_ip from enums import DeploymentEdition from extensions.ext_application_services import application_services from fields.base import ResponseModel @@ -21,6 +24,7 @@ NetworkAccessGroupAppNotFoundError, NetworkAccessGroupEntitlementUnavailableError, NetworkAccessGroupError, + NetworkAccessGroupInvalidPolicyError, NetworkAccessGroupUnsupportedAccessPointsError, NetworkAccessGroupUnsupportedAppModeError, NetworkAccessGroupUpstreamError, @@ -70,6 +74,8 @@ class NetworkAccessGroupAppResponse(ResponseModel): icon: str | None = None icon_type: str | None = None icon_background: str | None = None + mode: str + bound_agent_id: str | None = None class NetworkAccessGroupResponse(ResponseModel): @@ -84,6 +90,7 @@ class NetworkAccessGroupResponse(ResponseModel): ) version: int = Field(ge=1) used_by_count: int = Field(ge=0, validation_alias=AliasChoices("used_by_count", "usedByCount")) + enforcing_count: int = Field(ge=0, validation_alias=AliasChoices("enforcing_count", "enforcingCount")) app_ids: list[str] = Field( validation_alias=AliasChoices("app_ids", "appIds", "used_by_app_ids", "usedByAppIds"), ) @@ -95,6 +102,12 @@ class NetworkAccessGroupResponse(ResponseModel): created_at: datetime = Field(validation_alias=AliasChoices("created_at", "createdAt")) updated_at: datetime = Field(validation_alias=AliasChoices("updated_at", "updatedAt")) + @model_validator(mode="after") + def validate_enforcing_count(self) -> Self: + if self.enforcing_count > self.used_by_count: + raise ValueError("enforcing_count must not exceed used_by_count") + return self + class NetworkAccessGroupListResponse(ResponseModel): tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) @@ -110,6 +123,12 @@ class NetworkAccessGroupDeleteResponse(ResponseModel): deleted: bool +class NetworkAccessGroupCurrentIPCheckResponse(ResponseModel): + client_ip: str + allowed: bool + policy_version: int = Field(ge=1) + + class AppNetworkAccessGroupBindingResponse(ResponseModel): id: str tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) @@ -146,12 +165,14 @@ class AppNetworkAccessGroupResponse(ResponseModel): tenant_id: str = Field(validation_alias=AliasChoices("tenant_id", "tenantId")) app_id: str = Field(validation_alias=AliasChoices("app_id", "appId")) entitled: bool + effective_enabled: bool = Field(validation_alias=AliasChoices("effective_enabled", "effectiveEnabled")) available_access_points: list[NetworkAccessPoint] binding: AppNetworkAccessGroupBindingResponse | None class AppNetworkAccessGroupMutationResponse(ResponseModel): binding: AppNetworkAccessGroupBindingResponse + effective_enabled: bool = Field(validation_alias=AliasChoices("effective_enabled", "effectiveEnabled")) available_access_points: list[NetworkAccessPoint] @@ -168,6 +189,7 @@ class AppNetworkAccessGroupMutationResponse(ResponseModel): NetworkAccessGroupListResponse, NetworkAccessGroupMutationResponse, NetworkAccessGroupDeleteResponse, + NetworkAccessGroupCurrentIPCheckResponse, AppNetworkAccessGroupBindingResponse, AppNetworkAccessGroupResponse, AppNetworkAccessGroupMutationResponse, @@ -201,7 +223,7 @@ def _translate_service_error(exc: NetworkAccessGroupError) -> Exception: if isinstance(exc, NetworkAccessGroupUpstreamError): return _translate_upstream_error(exc) if isinstance(exc, NetworkAccessGroupAccessDeniedError): - return Forbidden("Only workspace owners and administrators can manage network access groups.") + return Forbidden("Your workspace role does not allow this network access operation.") if isinstance(exc, NetworkAccessGroupEntitlementUnavailableError): return ServiceUnavailable("Billing entitlement is temporarily unavailable.") if isinstance(exc, NetworkAccessGroupAppNotFoundError): @@ -210,6 +232,8 @@ def _translate_service_error(exc: NetworkAccessGroupError) -> Exception: return BadRequest(f"Network access control is not supported for app mode '{exc.app_mode}'.") if isinstance(exc, NetworkAccessGroupUnsupportedAccessPointsError): return BadRequest(f"Unsupported access points for this app: {', '.join(exc.access_points)}.") + if isinstance(exc, NetworkAccessGroupInvalidPolicyError): + return ServiceUnavailable("Network access policy cannot be evaluated.") raise TypeError(f"Unsupported network access group error: {type(exc).__name__}") @@ -220,6 +244,15 @@ def _serialize_response(response_model: type[ResponseModel], payload: dict[str, raise BadGateway("Invalid response from the network access group service.") from exc +def _resolve_current_network_access_client_ip() -> str: + trusted_proxy_cidrs = dify_config.NETWORK_ACCESS_TRUSTED_PROXY_CIDRS + if not trusted_proxy_cidrs.strip(): + # This Cloud-only UI must never label an internal Docker/ALB socket peer + # as the user's address when deployment trust configuration is missing. + raise NetworkAccessClientIPUnavailableError("network access trusted proxies are not configured") + return resolve_network_access_client_ip(request.environ, trusted_proxy_cidrs) + + @console_ns.route("/workspaces/current/network-access-groups") class CurrentWorkspaceNetworkAccessGroupsApi(Resource): @console_ns.response( @@ -333,6 +366,33 @@ def delete( return _serialize_response(NetworkAccessGroupDeleteResponse, payload) +@console_ns.route("/workspaces/current/network-access-groups//check-current-ip") +class CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi(Resource): + @console_ns.response( + 200, + "Current client IP checked against the workspace network access group", + console_ns.models[NetworkAccessGroupCurrentIPCheckResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, group_id: UUID) -> dict[str, Any]: + @after_this_request + def disable_client_ip_response_cache(response: Response) -> Response: + response.headers["Cache-Control"] = "no-store" + return response + + try: + payload = application_services().network_access_groups.check_current_ip( + request_context, + group_id=str(group_id), + client_ip_supplier=_resolve_current_network_access_client_ip, + ) + except NetworkAccessClientIPUnavailableError as exc: + raise ServiceUnavailable("Current client IP is unavailable.") from exc + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupCurrentIPCheckResponse, payload) + + @console_ns.route("/apps//network-access-group") class AppNetworkAccessGroupApi(Resource): @console_ns.response( diff --git a/api/core/network_access/__init__.py b/api/core/network_access/__init__.py new file mode 100644 index 00000000000000..2aedd5c9338e68 --- /dev/null +++ b/api/core/network_access/__init__.py @@ -0,0 +1 @@ +"""Network-access policy helpers shared by the Console control plane.""" diff --git a/api/core/network_access/client_ip.py b/api/core/network_access/client_ip.py new file mode 100644 index 00000000000000..b706e42a098a15 --- /dev/null +++ b/api/core/network_access/client_ip.py @@ -0,0 +1,95 @@ +"""Resolve a preview IP from a verified immediate peer and a strict proxy chain. + +This is intentionally independent of the historical login-IP helper. Deployers +must configure the same trusted proxy boundary as the data-plane Gateway. An +edge proxy must sanitize X-Forwarded-For or append its verified immediate peer; +an untrusted caller cannot choose its IP merely by supplying forwarding headers. +""" + +import ipaddress +from collections.abc import Mapping +from functools import lru_cache + +type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +type IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network + +MAX_PROXY_HOPS = 32 +MAX_FORWARDED_HEADER_LENGTH = 2048 + + +class NetworkAccessClientIPUnavailableError(ValueError): + """A reliable current client address cannot be determined.""" + + +def _address(value: object) -> IPAddress: + if not isinstance(value, str) or not value.strip() or "%" in value: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + try: + address = ipaddress.ip_address(value.strip()) + except ValueError as exc: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") from exc + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped + return address + + +@lru_cache(maxsize=16) +def _trusted_networks(config: str) -> tuple[IPNetwork, ...]: + if not config.strip(): + return () + entries = config.split(",") + if len(entries) > 256: + raise NetworkAccessClientIPUnavailableError("invalid network-access proxy configuration") + networks: list[IPNetwork] = [] + for entry in entries: + try: + # Prefixes are explicit; reject host bits, scoped addresses and + # empty entries rather than broadening a malformed trust boundary. + if not entry.strip() or "%" in entry or "/" not in entry: + raise ValueError("expected a proxy CIDR") + network = ipaddress.ip_network(entry.strip(), strict=True) + if isinstance(network, ipaddress.IPv6Network) and network.network_address.ipv4_mapped is not None: + if network.prefixlen < 96: + raise ValueError("invalid mapped proxy CIDR") + network = ipaddress.IPv4Network((network.network_address.ipv4_mapped, network.prefixlen - 96)) + except ValueError as exc: + raise NetworkAccessClientIPUnavailableError("invalid network-access proxy configuration") from exc + networks.append(network) + return tuple(networks) + + +def resolve_network_access_client_ip(environ: Mapping[str, object], trusted_proxy_cidrs: str) -> str: + """Return the first non-trusted hop, never a browser-supplied IP argument. + + If the actual socket peer is not trusted (including an empty trust list), + ignore all forwarding headers. If it is trusted, require a complete, bounded + XFF chain and strip trusted hops from the right. Invalid or all-trusted chains + are unavailable rather than silently returning a proxy's address. Preserve + the original peer when Werkzeug ProxyFix has rewritten REMOTE_ADDR. + """ + original = environ.get("werkzeug.proxy_fix.orig") + if original is not None: + if not isinstance(original, Mapping): + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + peer_value = original.get("REMOTE_ADDR") + else: + peer_value = environ.get("REMOTE_ADDR") + peer = _address(peer_value) + networks = _trusted_networks(trusted_proxy_cidrs) + + def trusted(address: IPAddress) -> bool: + return any(address.version == network.version and address in network for network in networks) + + if not trusted(peer): + return str(peer) + header = environ.get("HTTP_X_FORWARDED_FOR") + if not isinstance(header, str) or len(header) > MAX_FORWARDED_HEADER_LENGTH: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + values = header.split(",") + if len(values) > MAX_PROXY_HOPS: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + chain = [_address(value) for value in values] + for address in reversed(chain): + if not trusted(address): + return str(address) + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index c888afe0f5a351..f629458521d8ee 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -10817,6 +10817,19 @@ Update a plugin endpoint | ---- | ----------- | ------ | | 200 | Workspace network access group updated successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| +### [GET] /workspaces/current/network-access-groups/{group_id}/check-current-ip +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Current client IP checked against the workspace network access group | **application/json**: [NetworkAccessGroupCurrentIPCheckResponse](#networkaccessgroupcurrentipcheckresponse)
| + ### [GET] /workspaces/current/permission **Get workspace permission settings** @@ -16019,6 +16032,7 @@ AppMCPServer Status Enum | ---- | ---- | ----------- | -------- | | available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | | binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | +| effective_enabled | boolean | | Yes | #### AppNetworkAccessGroupResponse @@ -16027,6 +16041,7 @@ AppMCPServer Status Enum | app_id | string | | Yes | | available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | | binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | +| effective_enabled | boolean | | Yes | | entitled | boolean | | Yes | | tenant_id | string | | Yes | @@ -19948,10 +19963,12 @@ Model with provider entity. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| bound_agent_id | string | | No | | icon | string | | No | | icon_background | string | | No | | icon_type | string | | No | | id | string | | Yes | +| mode | string | | Yes | | name | string | | Yes | #### NetworkAccessGroupCreatePayload @@ -19962,6 +19979,14 @@ Model with provider entity. | description | string | | No | | name | string | | Yes | +#### NetworkAccessGroupCurrentIPCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed | boolean | | Yes | +| client_ip | string | | Yes | +| policy_version | integer | | Yes | + #### NetworkAccessGroupDeleteResponse | Name | Type | Description | Required | @@ -19991,6 +20016,7 @@ Model with provider entity. | apps | [ [NetworkAccessGroupAppResponse](#networkaccessgroupappresponse) ] | | Yes | | created_at | dateTime | | Yes | | description | string | | No | +| enforcing_count | integer | | Yes | | id | string | | Yes | | name | string | | Yes | | tenant_id | string | | Yes | diff --git a/api/repositories/network_access_group_repository.py b/api/repositories/network_access_group_repository.py index 942b7987307bcc..f94b6f3bef9b1a 100644 --- a/api/repositories/network_access_group_repository.py +++ b/api/repositories/network_access_group_repository.py @@ -3,12 +3,12 @@ from collections.abc import Sequence from typing import override -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, sessionmaker from models import App -from models.agent import AgentScope +from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus from services.network_access_group_service import ( NetworkAccessGroupAppQuery, NetworkAccessGroupAppQueryError, @@ -38,7 +38,14 @@ def get_manageable_app(self, *, workspace_id: str, app_id: str) -> NetworkAccess binding = app.agent_app_binding_with_session(session=session, include_archived=True) if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY: return None - return self._to_record(app) + bound_agent_id = ( + str(binding.id) + if binding is not None + and binding.scope == AgentScope.ROSTER + and binding.status == AgentStatus.ACTIVE + else None + ) + return self._to_record(app, bound_agent_id=bound_agent_id) except SQLAlchemyError as exc: raise NetworkAccessGroupAppQueryError from exc @@ -53,19 +60,56 @@ def list_apps( return () try: with self._session_factory() as session: - apps = session.scalars( - select(App).where( - App.tenant_id == workspace_id, - App.id.in_(app_ids), - App.status == "normal", + apps = tuple( + session.scalars( + select(App).where( + App.tenant_id == workspace_id, + App.id.in_(app_ids), + App.status == "normal", + ) + ).all() + ) + if not apps: + return () + + resolved_app_ids = tuple(app.id for app in apps) + agent_bindings = session.execute( + select(Agent.id, Agent.app_id, Agent.backing_app_id, Agent.scope).where( + Agent.tenant_id == workspace_id, + or_( + and_( + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.app_id.in_(resolved_app_ids), + ), + and_( + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.backing_app_id.in_(resolved_app_ids), + ), + ), ) ).all() - return tuple(self._to_record(app) for app in apps) + hidden_app_ids = { + backing_app_id + for _agent_id, _app_id, backing_app_id, scope in agent_bindings + if scope == AgentScope.WORKFLOW_ONLY and backing_app_id is not None + } + bound_agent_ids = { + app_id: str(agent_id) + for agent_id, app_id, _backing_app_id, scope in agent_bindings + if scope == AgentScope.ROSTER and app_id is not None + } + return tuple( + self._to_record(app, bound_agent_id=bound_agent_ids.get(app.id)) + for app in apps + if app.id not in hidden_app_ids + ) except SQLAlchemyError as exc: raise NetworkAccessGroupAppQueryError from exc @staticmethod - def _to_record(app: App) -> NetworkAccessGroupAppRecord: + def _to_record(app: App, *, bound_agent_id: str | None = None) -> NetworkAccessGroupAppRecord: return NetworkAccessGroupAppRecord( id=str(app.id), mode=app.mode.value, @@ -73,4 +117,5 @@ def _to_record(app: App) -> NetworkAccessGroupAppRecord: icon=app.icon, icon_type=app.icon_type.value if app.icon_type is not None else None, icon_background=app.icon_background, + bound_agent_id=bound_agent_id, ) diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 48e28585afa0a3..d692b90d779040 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -1,7 +1,8 @@ """Application service for workspace network access groups.""" +import ipaddress import logging -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Any, Literal, NamedTuple, Protocol from machinery.context import RequestContext @@ -10,6 +11,10 @@ NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"] +_POLICY_READ_ROLES = frozenset({"owner", "admin", "editor"}) +_POLICY_WRITE_ROLES = frozenset({"owner", "admin"}) +_APP_BINDING_ROLES = _POLICY_READ_ROLES + _ACCESS_POINTS_BY_APP_MODE: dict[str, tuple[NetworkAccessPoint, ...]] = { "workflow": ("webapp", "service_api", "mcp", "trigger"), "advanced-chat": ("webapp", "service_api", "mcp"), @@ -34,7 +39,7 @@ def __init__(self, status_code: int, reason: str | None = None) -> None: class NetworkAccessGroupAccessDeniedError(NetworkAccessGroupError): - """The actor is not a persisted owner or administrator of the workspace.""" + """The actor's persisted workspace role does not allow the operation.""" class NetworkAccessGroupEntitlementUnavailableError(NetworkAccessGroupError): @@ -57,6 +62,10 @@ def __init__(self, access_points: Sequence[str]) -> None: super().__init__(f"unsupported app access points: {', '.join(self.access_points)}") +class NetworkAccessGroupInvalidPolicyError(NetworkAccessGroupError): + """The control plane returned a policy that cannot be evaluated safely.""" + + class NetworkAccessGroupAppQueryError(Exception): """The local App read model could not be queried.""" @@ -68,6 +77,7 @@ class NetworkAccessGroupAppRecord(NamedTuple): icon: str | None icon_type: str | None icon_background: str | None + bound_agent_id: str | None = None class NetworkAccessGroupAppQuery(Protocol): @@ -157,7 +167,7 @@ def __init__( self._entitlement = entitlement def list_groups(self, context: RequestContext) -> dict[str, Any]: - self._ensure_workspace_admin_or_owner(context) + self._ensure_workspace_role(context, _POLICY_READ_ROLES) payload = self._control_plane.list_groups(context.active_workspace_id, context.account_id) payload["entitled"] = self._effective_entitlement( context.active_workspace_id, @@ -173,7 +183,7 @@ def create_group( description: str, allowed_cidrs: list[str], ) -> dict[str, Any]: - self._ensure_workspace_admin_or_owner(context) + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) payload = self._control_plane.create_group( context.active_workspace_id, name=name, @@ -184,7 +194,7 @@ def create_group( return self._enrich_app_references(payload, context.active_workspace_id) def get_group(self, context: RequestContext, *, group_id: str) -> dict[str, Any]: - self._ensure_workspace_admin_or_owner(context) + self._ensure_workspace_role(context, _POLICY_READ_ROLES) payload = self._control_plane.get_group( context.active_workspace_id, group_id, @@ -202,7 +212,7 @@ def update_group( allowed_cidrs: list[str], expected_version: int, ) -> dict[str, Any]: - self._ensure_workspace_admin_or_owner(context) + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) payload = self._control_plane.update_group( context.active_workspace_id, group_id, @@ -221,7 +231,7 @@ def delete_group( group_id: str, expected_version: int, ) -> dict[str, Any]: - self._ensure_workspace_admin_or_owner(context) + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) return self._control_plane.delete_group( context.active_workspace_id, group_id, @@ -230,8 +240,8 @@ def delete_group( ) def get_app_binding(self, context: RequestContext, *, app_id: str) -> dict[str, Any]: + self._ensure_workspace_role(context, _APP_BINDING_ROLES) app = self._get_manageable_app(context.active_workspace_id, app_id) - self._ensure_workspace_admin_or_owner(context) available_access_points = self._available_access_points(app.mode) payload = self._control_plane.get_app_binding( context.active_workspace_id, @@ -245,6 +255,7 @@ def get_app_binding(self, context: RequestContext, *, app_id: str) -> dict[str, payload.get("entitled"), ) payload["available_access_points"] = available_access_points + self._finalize_effective_enabled(payload, entitled=payload["entitled"]) return payload def update_app_binding( @@ -257,8 +268,8 @@ def update_app_binding( access_points: list[NetworkAccessPoint], expected_version: int, ) -> dict[str, Any]: + self._ensure_workspace_role(context, _APP_BINDING_ROLES) app = self._get_manageable_app(context.active_workspace_id, app_id) - self._ensure_workspace_admin_or_owner(context) available_access_points = self._available_access_points(app.mode) self._validate_app_access_points(access_points, available_access_points) payload = self._control_plane.update_app_binding( @@ -273,17 +284,63 @@ def update_app_binding( self._normalize_app_binding_defaults(payload) self._limit_binding_access_points(payload, available_access_points) payload["available_access_points"] = available_access_points + self._finalize_effective_enabled(payload) return payload + def check_current_ip( + self, + context: RequestContext, + *, + group_id: str, + client_ip_supplier: Callable[[], str], + ) -> dict[str, Any]: + """Evaluate the request's trusted client IP against one tenant policy. + + The supplier is deliberately lazy: persisted workspace authorization and + tenant-scoped policy resolution both complete before request IP metadata + is read or returned. + """ + + self._ensure_workspace_role(context, _POLICY_READ_ROLES) + payload = self._control_plane.get_group( + context.active_workspace_id, + group_id, + context.account_id, + ) + group = payload.get("group") + if not isinstance(group, dict): + raise NetworkAccessGroupInvalidPolicyError + + raw_cidrs = group.get("allowed_cidrs", group.get("allowedCidrs")) + raw_version = group.get("version") + if not isinstance(raw_cidrs, list): + raise NetworkAccessGroupInvalidPolicyError + if isinstance(raw_version, bool) or not isinstance(raw_version, (int, str)): + raise NetworkAccessGroupInvalidPolicyError + try: + policy_version = int(raw_version) + except (TypeError, ValueError) as exc: + raise NetworkAccessGroupInvalidPolicyError from exc + if policy_version < 1: + raise NetworkAccessGroupInvalidPolicyError + + client_ip = client_ip_supplier() + allowed = self._policy_allows_client_ip(raw_cidrs, client_ip) + return { + "client_ip": client_ip, + "allowed": allowed, + "policy_version": policy_version, + } + def cleanup_app_binding(self, *, workspace_id: str, app_id: str) -> dict[str, Any]: return self._control_plane.cleanup_app_binding(workspace_id, app_id) - def _ensure_workspace_admin_or_owner(self, context: RequestContext) -> None: + def _ensure_workspace_role(self, context: RequestContext, allowed_roles: frozenset[str]) -> None: role = self._memberships.get_role_for_account( workspace_id=context.active_workspace_id, account_id=context.account_id, ) - if role not in {"owner", "admin"}: + if role not in allowed_roles: raise NetworkAccessGroupAccessDeniedError def _effective_entitlement(self, workspace_id: str, upstream_entitled: object) -> bool: @@ -315,6 +372,10 @@ def _validate_app_access_points( def _normalize_app_binding_defaults(payload: dict[str, Any]) -> None: """Materialize ProtoJSON defaults so the Console response stays stable.""" + if "effective_enabled" not in payload: + payload["effective_enabled"] = payload.pop("effectiveEnabled", False) + else: + payload.pop("effectiveEnabled", None) payload.setdefault("binding", None) binding = payload.get("binding") if not isinstance(binding, dict): @@ -339,6 +400,58 @@ def _limit_binding_access_points( binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available] binding.pop("accessPoints", None) + @staticmethod + def _finalize_effective_enabled(payload: dict[str, Any], *, entitled: object = True) -> None: + binding = payload.get("binding") + if not isinstance(binding, dict): + payload["effective_enabled"] = False + return + group_id = binding.get("group_id", binding.get("groupId")) + access_points = binding.get("access_points", binding.get("accessPoints", [])) + payload["effective_enabled"] = bool( + payload.get("effective_enabled") + and entitled + and binding.get("enabled") + and group_id + and isinstance(access_points, list) + and access_points + ) + + @staticmethod + def _policy_allows_client_ip(raw_cidrs: Sequence[object], client_ip: str) -> bool: + if not raw_cidrs or "%" in client_ip: + raise NetworkAccessGroupInvalidPolicyError + try: + address = NetworkAccessGroupService._normalize_ip_address(ipaddress.ip_address(client_ip)) + networks = [NetworkAccessGroupService._normalize_ip_network(raw_cidr) for raw_cidr in raw_cidrs] + except (TypeError, ValueError) as exc: + raise NetworkAccessGroupInvalidPolicyError from exc + return any(address.version == network.version and address in network for network in networks) + + @staticmethod + def _normalize_ip_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, + ) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped + return address + + @staticmethod + def _normalize_ip_network(raw_cidr: object) -> ipaddress.IPv4Network | ipaddress.IPv6Network: + if not isinstance(raw_cidr, str) or "%" in raw_cidr: + raise TypeError("policy CIDR must be a string") + address_value, separator, prefix_value = raw_cidr.partition("/") + if not separator: + raise ValueError("policy CIDR must include a prefix length") + original_address = ipaddress.ip_address(address_value) + network = ipaddress.ip_network(raw_cidr, strict=False) + if not isinstance(original_address, ipaddress.IPv6Address) or original_address.ipv4_mapped is None: + return network + prefix_length = int(prefix_value) + if prefix_length < 96: + raise ValueError("IPv4-mapped IPv6 policy prefixes must be at least /96") + return ipaddress.IPv4Network((original_address.ipv4_mapped, prefix_length - 96), strict=False) + def _enrich_app_references(self, payload: dict[str, Any], workspace_id: str) -> dict[str, Any]: """Attach tenant-scoped App display metadata to one or more group payloads.""" @@ -364,6 +477,13 @@ def _enrich_app_references(self, payload: dict[str, Any], workspace_id: str) -> if raw_count is None: raw_count = len(raw_app_ids) if isinstance(raw_app_ids, list) else 0 group["used_by_count"] = raw_count + raw_enforcing_count = group.get("enforcing_count") + if raw_enforcing_count is None: + raw_enforcing_count = group.get("enforcingCount") + group["enforcing_count"] = ( + 0 if payload.get("entitled") is False or raw_enforcing_count is None else raw_enforcing_count + ) + group.pop("enforcingCount", None) app_ids = { str(app_id) @@ -395,6 +515,8 @@ def _enrich_app_references(self, payload: dict[str, Any], workspace_id: str) -> "icon": app.icon, "icon_type": app.icon_type, "icon_background": app.icon_background, + "mode": app.mode, + "bound_agent_id": app.bound_agent_id, } for app in app_records } diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 1b50ad3bf15a56..92b762860888e4 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -16,6 +16,7 @@ AppNetworkAccessGroupApi, AppNetworkAccessGroupUpdatePayload, CurrentWorkspaceNetworkAccessGroupApi, + CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi, CurrentWorkspaceNetworkAccessGroupsApi, NetworkAccessGroupCreatePayload, NetworkAccessGroupDeleteQuery, @@ -23,12 +24,14 @@ _translate_service_error, _translate_upstream_error, ) +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError from machinery.context import RequestContext from services.network_access_group_service import ( NetworkAccessGroupAccessDeniedError, NetworkAccessGroupAppNotFoundError, NetworkAccessGroupEntitlementUnavailableError, NetworkAccessGroupError, + NetworkAccessGroupInvalidPolicyError, NetworkAccessGroupUnsupportedAccessPointsError, NetworkAccessGroupUnsupportedAppModeError, NetworkAccessGroupUpstreamError, @@ -68,6 +71,7 @@ def _group_payload(*, app_ids: list[str] | None = None) -> dict[str, object]: "description": "Reusable office egress addresses", "allowedCidrs": ["203.0.113.7/32"], "usedByCount": len(app_ids), + "enforcingCount": 0, "usedByAppIds": app_ids, "apps": [], "version": "2", @@ -191,11 +195,13 @@ def test_app_get_and_put_forward_app_id_without_orm_models() -> None: "tenantId": TENANT_ID, "appId": APP_ID, "entitled": True, + "effectiveEnabled": False, "available_access_points": ["webapp", "service_api", "mcp"], "binding": None, } service.update_app_binding.return_value = { "binding": _binding_payload(), + "effectiveEnabled": True, "available_access_points": ["webapp", "service_api", "mcp"], } request_payload = AppNetworkAccessGroupUpdatePayload( @@ -225,7 +231,9 @@ def test_app_get_and_put_forward_app_id_without_orm_models() -> None: expected_version=2, ) assert get_result["binding"] is None + assert get_result["effective_enabled"] is False assert put_result["binding"]["version"] == 3 + assert put_result["effective_enabled"] is True @pytest.mark.parametrize( @@ -269,11 +277,12 @@ def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None @pytest.mark.parametrize( ("service_error", "expected_exception", "expected_message"), [ - (NetworkAccessGroupAccessDeniedError(), Forbidden, "owners and administrators"), + (NetworkAccessGroupAccessDeniedError(), Forbidden, "workspace role"), (NetworkAccessGroupEntitlementUnavailableError(), ServiceUnavailable, "temporarily unavailable"), (NetworkAccessGroupAppNotFoundError(), AppNotFoundError, "not found"), (NetworkAccessGroupUnsupportedAppModeError("channel"), BadRequest, "channel"), (NetworkAccessGroupUnsupportedAccessPointsError(["trigger"]), BadRequest, "trigger"), + (NetworkAccessGroupInvalidPolicyError(), ServiceUnavailable, "cannot be evaluated"), ], ) def test_application_error_mapping( @@ -322,7 +331,7 @@ def test_controller_maps_service_error_before_serialization() -> None: service = MagicMock() service.list_groups.side_effect = NetworkAccessGroupAccessDeniedError - with _application_services(service), pytest.raises(Forbidden, match="owners and administrators"): + with _application_services(service), pytest.raises(Forbidden, match="workspace role"): unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( CurrentWorkspaceNetworkAccessGroupsApi(), request_context=_request_context(), @@ -388,3 +397,98 @@ def test_app_config_accepts_policy_id_alias_and_disabled_draft() -> None: assert str(payload.group_id) == GROUP_ID assert payload.access_points == ["webapp", "service_api"] + + +def test_current_ip_check_uses_trusted_resolver_after_service_admission_and_disables_caching( + config_overrides: Callable[..., None], +) -> None: + app = Flask(__name__) + service = MagicMock() + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + + def check_current_ip(_context, *, group_id, client_ip_supplier): + assert group_id == GROUP_ID + return {"client_ip": client_ip_supplier(), "allowed": True, "policy_version": 4} + + service.check_current_ip.side_effect = check_current_ip + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context( + headers={"X-Forwarded-For": "203.0.113.7"}, + environ_base={"REMOTE_ADDR": "172.18.0.3"}, + ), + _application_services(service), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip", + return_value="203.0.113.7", + ) as resolve_client_ip, + ): + result = unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + response = app.process_response(app.make_response(result)) + + assert result == {"client_ip": "203.0.113.7", "allowed": True, "policy_version": 4} + assert response.headers["Cache-Control"] == "no-store" + assert resolve_client_ip.call_args.args[1] == "172.18.0.0/16" + + +def test_current_ip_check_maps_unavailable_client_ip_to_503(config_overrides: Callable[..., None]) -> None: + app = Flask(__name__) + service = MagicMock() + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + service.check_current_ip.side_effect = lambda _context, *, group_id, client_ip_supplier: ( + client_ip_supplier() if group_id == GROUP_ID else None + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context(environ_base={"REMOTE_ADDR": "172.18.0.3"}), + _application_services(service), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip", + side_effect=NetworkAccessClientIPUnavailableError, + ), + pytest.raises(ServiceUnavailable, match="Current client IP is unavailable"), + ): + unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + + +def test_current_ip_check_fails_closed_when_trusted_proxy_config_is_empty( + config_overrides: Callable[..., None], +) -> None: + app = Flask(__name__) + service = MagicMock() + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="") + service.check_current_ip.side_effect = lambda _context, *, group_id, client_ip_supplier: ( + client_ip_supplier() if group_id == GROUP_ID else None + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context(environ_base={"REMOTE_ADDR": "172.18.0.3"}), + _application_services(service), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip" + ) as resolve_client_ip, + pytest.raises(ServiceUnavailable, match="Current client IP is unavailable"), + ): + unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + + resolve_client_ip.assert_not_called() + + +def test_group_response_rejects_enforcing_count_greater_than_used_by_count() -> None: + service = MagicMock() + group = _group_payload(app_ids=[APP_ID]) + group["enforcingCount"] = 2 + service.list_groups.return_value = { + "tenantId": TENANT_ID, + "entitled": True, + "groups": [group], + } + + with _application_services(service), pytest.raises(BadGateway, match="Invalid response"): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), + ) diff --git a/api/tests/unit_tests/core/network_access/test_client_ip.py b/api/tests/unit_tests/core/network_access/test_client_ip.py new file mode 100644 index 00000000000000..f10f812eacf850 --- /dev/null +++ b/api/tests/unit_tests/core/network_access/test_client_ip.py @@ -0,0 +1,108 @@ +import pytest + +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError, resolve_network_access_client_ip + +TRUSTED = "172.18.0.0/16,10.0.0.0/8,2400:cb00::/32" + + +@pytest.mark.parametrize("config", ["", TRUSTED]) +def test_untrusted_socket_ignores_all_forwarding_headers(config: str): + assert ( + resolve_network_access_client_ip( + { + "REMOTE_ADDR": "203.0.113.42", + "HTTP_X_FORWARDED_FOR": "192.0.2.1, invalid", + "HTTP_CF_CONNECTING_IP": "192.0.2.1", + "HTTP_X_REAL_IP": "192.0.2.1", + }, + config, + ) + == "203.0.113.42" + ) + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + ("203.0.113.42", "203.0.113.42"), + ("192.0.2.1, 203.0.113.42, 10.2.3.4", "203.0.113.42"), + ("2001:db8::1234, 2400:cb00::1, 10.2.3.4", "2001:db8::1234"), + ("::ffff:203.0.113.42, ::ffff:10.2.3.4", "203.0.113.42"), + (" 2001:0db8:0:0::1234 , 10.2.3.4 ", "2001:db8::1234"), + ], +) +def test_trusted_chain_strips_only_rightmost_trusted_hops(header: str, expected: str): + assert ( + resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) + == expected + ) + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "203.0.113.42,", + ",203.0.113.42", + "unknown", + "203.0.113.42:443", + "[2001:db8::1]", + "fe80::1%eth0", + "192.0.2.1,invalid", + "10.1.2.3", + "192.0.2.1," * 33, + "x" * 2049, + ], +) +def test_trusted_peer_rejects_missing_invalid_or_all_trusted_chains(header: object): + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) + + +@pytest.mark.parametrize("peer", [None, "", "unknown", "[::1]", "203.0.113.42:5000", "fe80::1%eth0", 1]) +def test_invalid_socket_peer_is_unavailable(peer: object): + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": peer}, TRUSTED) + + +def test_proxy_fix_cannot_make_untrusted_origin_appear_trusted(): + environ = { + "REMOTE_ADDR": "172.18.0.2", + "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "203.0.113.42"}, + "HTTP_X_FORWARDED_FOR": "192.0.2.1", + } + assert resolve_network_access_client_ip(environ, TRUSTED) == "203.0.113.42" + + +def test_proxy_fix_preserves_trusted_socket_even_after_remote_addr_rewrite(): + environ = { + "REMOTE_ADDR": "192.0.2.1", + "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "172.18.0.2"}, + "HTTP_X_FORWARDED_FOR": "192.0.2.1, 203.0.113.42", + } + assert resolve_network_access_client_ip(environ, TRUSTED) == "203.0.113.42" + + +@pytest.mark.parametrize("original", ["invalid", {}, {"REMOTE_ADDR": None}]) +def test_malformed_proxy_fix_original_is_unavailable(original: object): + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42", "werkzeug.proxy_fix.orig": original}, TRUSTED) + + +@pytest.mark.parametrize( + "config", ["not-a-cidr", "172.18.0.1/16", "172.18.0.0/16,", "fe80::%eth0/64", "10.0.0.0/8," * 257] +) +def test_invalid_proxy_configuration_is_unavailable(config: str): + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42"}, config) + + +def test_mapped_socket_and_proxy_cidr_match_ipv4(): + assert ( + resolve_network_access_client_ip( + {"REMOTE_ADDR": "::ffff:172.18.0.2", "HTTP_X_FORWARDED_FOR": "::ffff:203.0.113.42"}, + "::ffff:172.18.0.0/112", + ) + == "203.0.113.42" + ) diff --git a/api/tests/unit_tests/repositories/test_network_access_group_repository.py b/api/tests/unit_tests/repositories/test_network_access_group_repository.py index 49ce7a0fa7c087..59b1f3e92a8705 100644 --- a/api/tests/unit_tests/repositories/test_network_access_group_repository.py +++ b/api/tests/unit_tests/repositories/test_network_access_group_repository.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock import pytest -from sqlalchemy import text +from sqlalchemy import event, text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, sessionmaker @@ -158,6 +158,85 @@ def test_get_manageable_app_applies_agent_scope_and_lifecycle_visibility( ) assert (result is not None) is expected_visible + if result is not None: + assert result.bound_agent_id == "77777777-7777-7777-7777-777777777777" + + +def test_list_apps_batch_loads_agent_routes_and_excludes_hidden_workflow_backing_apps( + sqlite_session_factory: sessionmaker[Session], +) -> None: + agent_app_id = _APP_ID + second_agent_app_id = _UNREQUESTED_APP_ID + hidden_app_id = _DISABLED_APP_ID + with sqlite_session_factory() as session: + session.add_all( + [ + _app(agent_app_id, mode=AppMode.AGENT), + _app(second_agent_app_id, mode=AppMode.AGENT), + _app(hidden_app_id, mode=AppMode.AGENT), + Agent( + id="77777777-7777-7777-7777-777777777777", + tenant_id=_WORKSPACE_ID, + name="First roster agent", + description="", + role="", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id=agent_app_id, + backing_app_id=agent_app_id, + ), + Agent( + id="88888888-8888-8888-8888-888888888888", + tenant_id=_WORKSPACE_ID, + name="Second roster agent", + description="", + role="", + scope=AgentScope.ROSTER, + source=AgentSource.IMPORTED, + status=AgentStatus.ACTIVE, + app_id=second_agent_app_id, + backing_app_id=second_agent_app_id, + ), + Agent( + id="99999999-9999-9999-9999-999999999999", + tenant_id=_WORKSPACE_ID, + name="Workflow-only agent", + description="", + role="", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id=_OTHER_APP_ID, + backing_app_id=hidden_app_id, + workflow_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + workflow_node_id="agent-node", + ), + ] + ) + session.commit() + engine = session.get_bind() + + agent_selects: list[str] = [] + + def record_agent_select(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + if "FROM agents" in statement: + agent_selects.append(statement) + + event.listen(engine, "before_cursor_execute", record_agent_select) + try: + result = _repository(sqlite_session_factory).list_apps( + workspace_id=_WORKSPACE_ID, + app_ids=[agent_app_id, second_agent_app_id, hidden_app_id], + ) + finally: + event.remove(engine, "before_cursor_execute", record_agent_select) + + assert [(app.id, app.bound_agent_id) for app in result] == [ + (agent_app_id, "77777777-7777-7777-7777-777777777777"), + (second_agent_app_id, "88888888-8888-8888-8888-888888888888"), + ] + assert len(agent_selects) == 1 def test_list_apps_maps_database_failures_to_query_error() -> None: diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py index 2e4a948cd1a7c7..6b93bcf59e0573 100644 --- a/api/tests/unit_tests/services/test_network_access_group_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -13,9 +13,11 @@ NetworkAccessGroupAppRecord, NetworkAccessGroupControlPlane, NetworkAccessGroupEntitlement, + NetworkAccessGroupInvalidPolicyError, NetworkAccessGroupService, NetworkAccessGroupUnsupportedAccessPointsError, NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, WorkspaceMembershipRoleQuery, ) @@ -42,7 +44,13 @@ def _context() -> RequestContext: ) -def _app(*, app_id: str = APP_ID, mode: str = "workflow", name: str = "App") -> NetworkAccessGroupAppRecord: +def _app( + *, + app_id: str = APP_ID, + mode: str = "workflow", + name: str = "App", + bound_agent_id: str | None = None, +) -> NetworkAccessGroupAppRecord: return NetworkAccessGroupAppRecord( id=app_id, mode=mode, @@ -50,6 +58,7 @@ def _app(*, app_id: str = APP_ID, mode: str = "workflow", name: str = "App") -> icon=f"{app_id}.png", icon_type="image", icon_background="#FFFFFF", + bound_agent_id=bound_agent_id, ) @@ -153,14 +162,111 @@ def test_mutations_reject_non_privileged_persisted_roles_before_control_plane( assert harness.control_plane.method_calls == [] -@pytest.mark.parametrize("role", ["owner", "admin"]) -def test_reads_allow_persisted_owner_and_admin(role: str) -> None: +@pytest.mark.parametrize("role", ["owner", "admin", "editor"]) +def test_policy_reads_allow_persisted_owner_admin_and_editor(role: str) -> None: harness = _harness(role=role) harness.control_plane.list_groups.return_value = {"entitled": True, "groups": list[object]()} + harness.control_plane.get_group.return_value = {"group": {"id": GROUP_ID}} harness.service.list_groups(_context()) + harness.service.get_group(_context(), group_id=GROUP_ID) harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID) + harness.control_plane.get_group.assert_called_once_with(WORKSPACE_ID, GROUP_ID, ACCOUNT_ID) + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param( + lambda service, context: service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ), + id="create-group", + ), + pytest.param( + lambda service, context: service.update_group( + context, + group_id=GROUP_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ), + id="update-group", + ), + pytest.param( + lambda service, context: service.delete_group( + context, + group_id=GROUP_ID, + expected_version=1, + ), + id="delete-group", + ), + ], +) +def test_policy_mutations_reject_editor_before_control_plane(mutation: Mutation) -> None: + harness = _harness(role="editor") + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + mutation(harness.service, _context()) + + assert harness.control_plane.method_calls == [] + + +def test_app_binding_get_and_put_allow_editor() -> None: + harness = _harness(role="editor") + harness.control_plane.get_app_binding.return_value = {"entitled": True, "binding": None} + harness.control_plane.update_app_binding.return_value = { + "effective_enabled": True, + "binding": {"enabled": True, "group_id": GROUP_ID, "access_points": ["webapp"]}, + } + + harness.service.get_app_binding(_context(), app_id=APP_ID) + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ) + + harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.update_app_binding.assert_called_once() + + +@pytest.mark.parametrize( + "operation", + [ + pytest.param( + lambda service, context: service.get_app_binding(context, app_id=APP_ID), + id="get", + ), + pytest.param( + lambda service, context: service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ), + id="put", + ), + ], +) +def test_app_binding_rejects_normal_role_before_app_lookup(operation: Mutation) -> None: + harness = _harness(role="normal") + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + operation(harness.service, _context()) + + harness.apps.get_manageable_app.assert_not_called() + assert harness.control_plane.method_calls == [] def test_mutation_does_not_repeat_http_paid_plan_admission() -> None: @@ -328,7 +434,10 @@ def test_app_binding_rejects_missing_or_inaccessible_app_before_control_plane() harness.service.get_app_binding(_context(), app_id=APP_ID) harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) - harness.memberships.get_role_for_account.assert_not_called() + harness.memberships.get_role_for_account.assert_called_once_with( + workspace_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) harness.control_plane.get_app_binding.assert_not_called() @@ -347,7 +456,10 @@ def test_update_app_binding_rejects_missing_or_inaccessible_app_before_control_p ) harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) - harness.memberships.get_role_for_account.assert_not_called() + harness.memberships.get_role_for_account.assert_called_once_with( + workspace_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) harness.control_plane.update_app_binding.assert_not_called() @@ -378,6 +490,7 @@ def test_app_binding_materializes_protojson_defaults() -> None: result = harness.service.get_app_binding(_context(), app_id=APP_ID) assert result["binding"] == {"enabled": False, "access_points": []} + assert result["effective_enabled"] is False def test_app_binding_normalizes_camel_case_and_filters_stale_access_points() -> None: @@ -390,6 +503,7 @@ def test_app_binding_normalizes_camel_case_and_filters_stale_access_points() -> result = harness.service.get_app_binding(_context(), app_id=APP_ID) assert result["binding"] == {"enabled": True, "access_points": ["webapp", "mcp"]} + assert result["effective_enabled"] is False def test_group_payloads_are_enriched_with_tenant_scoped_app_metadata() -> None: @@ -424,6 +538,8 @@ def test_group_payloads_are_enriched_with_tenant_scoped_app_metadata() -> None: "icon": "app-1.png", "icon_type": "image", "icon_background": "#FFFFFF", + "mode": "workflow", + "bound_agent_id": None, } ] call_kwargs = harness.apps.list_apps.call_args.kwargs @@ -442,3 +558,183 @@ def test_app_query_failure_degrades_group_enrichment_to_empty_apps() -> None: assert result["group"]["app_ids"] == [APP_ID] assert result["group"]["apps"] == [] + + +@pytest.mark.parametrize(("paid", "expected"), [(True, True), (False, False)]) +def test_app_effective_enabled_is_bounded_by_final_entitlement(paid: bool, expected: bool) -> None: + harness = _harness(paid=paid, app_mode="chat") + harness.control_plane.get_app_binding.return_value = { + "entitled": True, + "effectiveEnabled": True, + "binding": { + "enabled": True, + "groupId": GROUP_ID, + "accessPoints": ["webapp", "service_api"], + }, + } + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result["effective_enabled"] is expected + assert result["binding"]["enabled"] is True + + +def test_app_effective_enabled_does_not_reenable_an_incomplete_filtered_binding() -> None: + harness = _harness(app_mode="agent") + harness.control_plane.update_app_binding.return_value = { + "effectiveEnabled": True, + "binding": { + "enabled": True, + "groupId": GROUP_ID, + "accessPoints": ["mcp"], + }, + } + + result = harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=False, + group_id=GROUP_ID, + access_points=[], + expected_version=1, + ) + + assert result["binding"]["enabled"] is True + assert result["binding"]["access_points"] == [] + assert result["effective_enabled"] is False + + +@pytest.mark.parametrize(("paid", "expected"), [(True, 2), (False, 0)]) +def test_group_enforcing_count_is_normalized_and_bounded_by_final_entitlement( + paid: bool, + expected: int, +) -> None: + harness = _harness(paid=paid) + harness.control_plane.list_groups.return_value = { + "entitled": True, + "groups": [{"id": GROUP_ID, "enforcingCount": 2, "usedByCount": 3}], + } + + result = harness.service.list_groups(_context()) + + assert result["groups"][0]["enforcing_count"] == expected + + +def test_group_enforcing_count_materializes_omitted_protojson_zero() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = {"group": {"id": GROUP_ID}} + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result["group"]["enforcing_count"] == 0 + + +def test_group_enrichment_includes_agent_routing_metadata() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = { + "group": {"id": GROUP_ID, "used_by_app_ids": [APP_ID], "used_by_count": 1} + } + harness.apps.list_apps.return_value = [_app(app_id=APP_ID, mode="agent", name="Agent", bound_agent_id="agent-1")] + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result["group"]["apps"] == [ + { + "id": APP_ID, + "name": "Agent", + "icon": f"{APP_ID}.png", + "icon_type": "image", + "icon_background": "#FFFFFF", + "mode": "agent", + "bound_agent_id": "agent-1", + } + ] + + +def test_current_ip_check_authorizes_and_loads_tenant_policy_before_reading_ip() -> None: + harness = _harness(role="editor") + order: list[str] = [] + harness.memberships.get_role_for_account.side_effect = lambda **_kwargs: order.append("role") or "editor" + harness.control_plane.get_group.side_effect = lambda *_args: ( + order.append("policy") or {"group": {"version": "7", "allowedCidrs": ["2001:db8::/32"]}} + ) + + result = harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: order.append("ip") or "2001:db8::42", + ) + + assert order == ["role", "policy", "ip"] + assert result == {"client_ip": "2001:db8::42", "allowed": True, "policy_version": 7} + + +def test_current_ip_check_rejects_role_before_policy_or_ip_lookup() -> None: + harness = _harness(role="normal") + supplier = MagicMock(return_value="203.0.113.7") + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + harness.service.check_current_ip(_context(), group_id=GROUP_ID, client_ip_supplier=supplier) + + harness.control_plane.get_group.assert_not_called() + supplier.assert_not_called() + + +def test_current_ip_check_does_not_read_ip_when_tenant_scoped_policy_lookup_fails() -> None: + harness = _harness(role="admin") + harness.control_plane.get_group.side_effect = NetworkAccessGroupUpstreamError(404) + supplier = MagicMock(return_value="203.0.113.7") + + with pytest.raises(NetworkAccessGroupUpstreamError): + harness.service.check_current_ip(_context(), group_id=GROUP_ID, client_ip_supplier=supplier) + + supplier.assert_not_called() + + +@pytest.mark.parametrize( + ("allowed_cidrs", "client_ip", "expected"), + [ + (["203.0.113.0/24"], "::ffff:203.0.113.7", True), + (["::ffff:203.0.113.0/120"], "203.0.113.7", True), + (["2001:db8::/32"], "2001:db8:1::1", True), + (["2001:db8::/48"], "2001:db9::1", False), + ], +) +def test_current_ip_check_matches_ipv4_ipv6_and_mapped_addresses_consistently( + allowed_cidrs: list[str], + client_ip: str, + expected: bool, +) -> None: + harness = _harness() + harness.control_plane.get_group.return_value = {"group": {"version": 1, "allowed_cidrs": allowed_cidrs}} + + result = harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: client_ip, + ) + + assert result["allowed"] is expected + + +@pytest.mark.parametrize( + "allowed_cidrs", + [ + [], + ["0.0.0.0/0", "not-a-cidr"], + ["fe80::1%eth0/128"], + ["::ffff:0:0/80"], + ], +) +def test_current_ip_check_rejects_malformed_policy_without_short_circuiting( + allowed_cidrs: list[str], +) -> None: + harness = _harness() + harness.control_plane.get_group.return_value = {"group": {"version": 1, "allowed_cidrs": allowed_cidrs}} + + with pytest.raises(NetworkAccessGroupInvalidPolicyError): + harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: "203.0.113.7", + ) diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example index 9e97522e395dc8..649d3f1641c1c3 100644 --- a/docker/envs/core-services/api.env.example +++ b/docker/envs/core-services/api.env.example @@ -3,6 +3,11 @@ # ------------------------------ MODE=api +# Optional SaaS network-policy control plane and current-IP preview trust. +NETWORK_ACCESS_API_URL= +# Comma-separated proxy CIDRs; empty disables Cloud IP previews. The edge +# must sanitize/append XFF and the trust boundary must match Gateway. +NETWORK_ACCESS_TRUSTED_PROXY_CIDRS= SENTRY_DSN= SENTRY_TRACES_SAMPLE_RATE=1.0 SENTRY_PROFILES_SAMPLE_RATE=1.0 diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 55525959eaecf5..1b171fcbf6d0c5 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -576,6 +576,7 @@ export type AppNetworkAccessGroupResponse = { app_id: string available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> binding: AppNetworkAccessGroupBindingResponse | null + effective_enabled: boolean entitled: boolean tenant_id: string } @@ -590,6 +591,7 @@ export type AppNetworkAccessGroupUpdatePayload = { export type AppNetworkAccessGroupMutationResponse = { available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> binding: AppNetworkAccessGroupBindingResponse + effective_enabled: boolean } export type RedirectUrlResponse = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index e168b3ba360735..ec3c5cda36c466 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1329,6 +1329,7 @@ export const zAppNetworkAccessGroupResponse = z.object({ app_id: z.string(), available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), binding: zAppNetworkAccessGroupBindingResponse.nullable(), + effective_enabled: z.boolean(), entitled: z.boolean(), tenant_id: z.string(), }) @@ -1339,6 +1340,7 @@ export const zAppNetworkAccessGroupResponse = z.object({ export const zAppNetworkAccessGroupMutationResponse = z.object({ available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), binding: zAppNetworkAccessGroupBindingResponse, + effective_enabled: z.boolean(), }) /** diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 8af7030a7fa010..20779120424fd2 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -88,6 +88,8 @@ import { zGetWorkspacesCurrentModelProvidersSummaryResponse, zGetWorkspacesCurrentModelsModelTypesByModelTypePath, zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse, zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, zGetWorkspacesCurrentNetworkAccessGroupsResponse, @@ -1736,6 +1738,21 @@ export const models2 = { modelTypes, } +export const get22 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIp', + path: '/workspaces/current/network-access-groups/{group_id}/check-current-ip', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath })) + .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse) + +export const checkCurrentIp = { + get: get22, +} + export const delete8 = oc .route({ inputStructure: 'detailed', @@ -1752,7 +1769,7 @@ export const delete8 = oc ) .output(zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) -export const get22 = oc +export const get23 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1781,11 +1798,12 @@ export const put5 = oc export const byGroupId = { delete: delete8, - get: get22, + get: get23, put: put5, + checkCurrentIp, } -export const get23 = oc +export const get24 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1808,7 +1826,7 @@ export const post26 = oc .output(zPostWorkspacesCurrentNetworkAccessGroupsResponse) export const networkAccessGroups = { - get: get23, + get: get24, post: post26, byGroupId, } @@ -1818,7 +1836,7 @@ export const networkAccessGroups = { * * Returns permission flags that control workspace features like member invitations and owner transfer. */ -export const get24 = oc +export const get25 = oc .route({ description: 'Returns permission flags that control workspace features like member invitations and owner transfer.', @@ -1832,10 +1850,10 @@ export const get24 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get24, + get: get25, } -export const get25 = oc +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1847,7 +1865,7 @@ export const get25 = oc .output(zGetWorkspacesCurrentPluginAssetResponse) export const asset = { - get: get25, + get: get26, } export const post27 = oc @@ -1880,7 +1898,7 @@ export const exclude = { post: post28, } -export const get26 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1892,7 +1910,7 @@ export const get26 = oc .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) export const fetch_ = { - get: get26, + get: get27, } export const autoUpgrade = { @@ -1901,7 +1919,7 @@ export const autoUpgrade = { fetch: fetch_, } -export const get27 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1912,10 +1930,10 @@ export const get27 = oc .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) export const debuggingKey = { - get: get27, + get: get28, } -export const get28 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1927,10 +1945,10 @@ export const get28 = oc .output(zGetWorkspacesCurrentPluginFetchManifestResponse) export const fetchManifest = { - get: get28, + get: get29, } -export const get29 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1942,7 +1960,7 @@ export const get29 = oc .output(zGetWorkspacesCurrentPluginIconResponse) export const icon = { - get: get29, + get: get30, } export const post29 = oc @@ -1996,7 +2014,7 @@ export const install = { pkg, } -export const get30 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2008,7 +2026,7 @@ export const get30 = oc .output(zGetWorkspacesCurrentPluginInstalledIdsResponse) export const installedIds = { - get: get30, + get: get31, } export const post32 = oc @@ -2045,7 +2063,7 @@ export const latestVersions = { post: post33, } -export const get31 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2057,12 +2075,12 @@ export const get31 = oc .output(zGetWorkspacesCurrentPluginListResponse) export const list2 = { - get: get31, + get: get32, installations, latestVersions, } -export const get32 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2074,14 +2092,14 @@ export const get32 = oc .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) export const pkg2 = { - get: get32, + get: get33, } export const marketplace2 = { pkg: pkg2, } -export const get33 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2093,7 +2111,7 @@ export const get33 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get33, + get: get34, } /** @@ -2137,7 +2155,7 @@ export const change2 = { post: post35, } -export const get34 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2148,7 +2166,7 @@ export const get34 = oc .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) export const fetch2 = { - get: get34, + get: get35, } export const permission2 = { @@ -2156,7 +2174,7 @@ export const permission2 = { fetch: fetch2, } -export const get35 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2168,7 +2186,7 @@ export const get35 = oc .output(zGetWorkspacesCurrentPluginReadmeResponse) export const readme = { - get: get35, + get: get36, } export const post36 = oc @@ -2216,7 +2234,7 @@ export const delete9 = { byIdentifier, } -export const get36 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2228,11 +2246,11 @@ export const get36 = oc .output(zGetWorkspacesCurrentPluginTasksByTaskIdResponse) export const byTaskId = { - get: get36, + get: get37, delete: delete9, } -export const get37 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2244,7 +2262,7 @@ export const get37 = oc .output(zGetWorkspacesCurrentPluginTasksResponse) export const tasks = { - get: get37, + get: get38, deleteAll, byTaskId, } @@ -2349,7 +2367,7 @@ export const upload = { pkg: pkg3, } -export const get38 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2366,7 +2384,7 @@ export const get38 = oc .output(zGetWorkspacesCurrentPluginByCategoryListResponse) export const list3 = { - get: get38, + get: get39, } export const byCategory = { @@ -2419,7 +2437,7 @@ export const delete10 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const get39 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2448,12 +2466,12 @@ export const put6 = oc export const byPolicyId = { delete: delete10, - get: get39, + get: get40, put: put6, copy, } -export const get40 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2476,7 +2494,7 @@ export const post46 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) export const accessPolicies = { - get: get40, + get: get41, post: post46, byPolicyId, } @@ -2536,7 +2554,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get41 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2553,10 +2571,10 @@ export const get41 = oc export const memberBindings = { delete: delete11, - get: get41, + get: get42, } -export const get42 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2572,7 +2590,7 @@ export const get42 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings = { - get: get42, + get: get43, } export const byPolicyId2 = { @@ -2584,7 +2602,7 @@ export const accessPolicies2 = { byPolicyId: byPolicyId2, } -export const get43 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2601,10 +2619,10 @@ export const get43 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPolicyResponse) export const accessPolicy = { - get: get43, + get: get44, } -export const get44 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2621,7 +2639,7 @@ export const get44 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdUserAccessPoliciesResponse) export const userAccessPolicies = { - get: get44, + get: get45, } export const put9 = oc @@ -2652,7 +2670,7 @@ export const users = { byTargetAccountId, } -export const get45 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2680,11 +2698,11 @@ export const put10 = oc .output(zPutWorkspacesCurrentRbacAgentsByAgentIdWhitelistResponse) export const whitelist = { - get: get45, + get: get46, put: put10, } -export const get46 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2696,7 +2714,7 @@ export const get46 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistConfigResponse) export const whitelistConfig = { - get: get46, + get: get47, } export const byAgentId2 = { @@ -2728,7 +2746,7 @@ export const delete12 = oc ) .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get47 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2745,10 +2763,10 @@ export const get47 = oc export const memberBindings2 = { delete: delete12, - get: get47, + get: get48, } -export const get48 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2764,7 +2782,7 @@ export const get48 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings2 = { - get: get48, + get: get49, } export const byPolicyId3 = { @@ -2776,7 +2794,7 @@ export const accessPolicies4 = { byPolicyId: byPolicyId3, } -export const get49 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2793,10 +2811,10 @@ export const get49 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) export const accessPolicy2 = { - get: get49, + get: get50, } -export const get50 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2813,7 +2831,7 @@ export const get50 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) export const userAccessPolicies2 = { - get: get50, + get: get51, } export const put11 = oc @@ -2844,7 +2862,7 @@ export const users2 = { byTargetAccountId: byTargetAccountId2, } -export const get51 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2872,11 +2890,11 @@ export const put12 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) export const whitelist2 = { - get: get51, + get: get52, put: put12, } -export const get52 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2888,7 +2906,7 @@ export const get52 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistConfigResponse) export const whitelistConfig2 = { - get: get52, + get: get53, } export const byAppId = { @@ -2924,7 +2942,7 @@ export const delete13 = oc zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, ) -export const get53 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2945,10 +2963,10 @@ export const get53 = oc export const memberBindings3 = { delete: delete13, - get: get53, + get: get54, } -export const get54 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2964,7 +2982,7 @@ export const get54 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings3 = { - get: get54, + get: get55, } export const byPolicyId4 = { @@ -2976,7 +2994,7 @@ export const accessPolicies6 = { byPolicyId: byPolicyId4, } -export const get55 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2993,10 +3011,10 @@ export const get55 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) export const accessPolicy3 = { - get: get55, + get: get56, } -export const get56 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3013,7 +3031,7 @@ export const get56 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) export const userAccessPolicies3 = { - get: get56, + get: get57, } export const put13 = oc @@ -3044,7 +3062,7 @@ export const users3 = { byTargetAccountId: byTargetAccountId3, } -export const get57 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3072,11 +3090,11 @@ export const put14 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) export const whitelist3 = { - get: get57, + get: get58, put: put14, } -export const get58 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3088,7 +3106,7 @@ export const get58 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistConfigResponse) export const whitelistConfig3 = { - get: get58, + get: get59, } export const byDatasetId = { @@ -3104,7 +3122,7 @@ export const datasets = { byDatasetId, } -export const get59 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3132,7 +3150,7 @@ export const put15 = oc .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const rbacRoles = { - get: get59, + get: get60, put: put15, } @@ -3144,7 +3162,7 @@ export const members2 = { byMemberId: byMemberId2, } -export const get60 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3155,10 +3173,10 @@ export const get60 = oc .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) export const myPermissions = { - get: get60, + get: get61, } -export const get61 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3169,10 +3187,10 @@ export const get61 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAgentResponse) export const agent = { - get: get61, + get: get62, } -export const get62 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3183,10 +3201,10 @@ export const get62 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) export const app = { - get: get62, + get: get63, } -export const get63 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3197,10 +3215,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) export const dataset = { - get: get63, + get: get64, } -export const get64 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3211,7 +3229,7 @@ export const get64 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) export const catalog = { - get: get64, + get: get65, agent, app, dataset, @@ -3236,7 +3254,7 @@ export const copy2 = { post: post47, } -export const get65 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3248,7 +3266,7 @@ export const get65 = oc .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { - get: get65, + get: get66, } export const delete14 = oc @@ -3262,7 +3280,7 @@ export const delete14 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) -export const get66 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3286,13 +3304,13 @@ export const put16 = oc export const byRoleId = { delete: delete14, - get: get66, + get: get67, put: put16, copy: copy2, members: members3, } -export const get67 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3313,7 +3331,7 @@ export const post48 = oc .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { - get: get67, + get: get68, post: post48, byRoleId, } @@ -3338,7 +3356,7 @@ export const bindings = { put: put17, } -export const get68 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3354,10 +3372,10 @@ export const get68 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings4 = { - get: get68, + get: get69, } -export const get69 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3373,7 +3391,7 @@ export const get69 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings4 = { - get: get69, + get: get70, } export const byPolicyId5 = { @@ -3386,7 +3404,7 @@ export const accessPolicies8 = { byPolicyId: byPolicyId5, } -export const get70 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3397,7 +3415,7 @@ export const get70 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPolicyResponse) export const accessPolicy4 = { - get: get70, + get: get71, } export const agents3 = { @@ -3425,7 +3443,7 @@ export const bindings2 = { put: put18, } -export const get71 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3441,10 +3459,10 @@ export const get71 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings5 = { - get: get71, + get: get72, } -export const get72 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3460,7 +3478,7 @@ export const get72 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings5 = { - get: get72, + get: get73, } export const byPolicyId6 = { @@ -3473,7 +3491,7 @@ export const accessPolicies9 = { byPolicyId: byPolicyId6, } -export const get73 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3484,7 +3502,7 @@ export const get73 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) export const accessPolicy5 = { - get: get73, + get: get74, } export const apps2 = { @@ -3512,7 +3530,7 @@ export const bindings3 = { put: put19, } -export const get74 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3528,10 +3546,10 @@ export const get74 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings6 = { - get: get74, + get: get75, } -export const get75 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3547,7 +3565,7 @@ export const get75 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings6 = { - get: get75, + get: get76, } export const byPolicyId7 = { @@ -3560,7 +3578,7 @@ export const accessPolicies10 = { byPolicyId: byPolicyId7, } -export const get76 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3571,7 +3589,7 @@ export const get76 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) export const accessPolicy6 = { - get: get76, + get: get77, } export const datasets2 = { @@ -3637,7 +3655,7 @@ export const import_ = { post: post50, } -export const get77 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3648,7 +3666,7 @@ export const get77 = oc .output(zGetWorkspacesCurrentSkillsTagsResponse) export const tags = { - get: get77, + get: get78, } export const post51 = oc @@ -3691,7 +3709,7 @@ export const duplicate = { post: post52, } -export const get78 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3703,7 +3721,7 @@ export const get78 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdExportResponse) export const export2 = { - get: get78, + get: get79, } export const post53 = oc @@ -3726,7 +3744,7 @@ export const check = { post: post53, } -export const get79 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3743,10 +3761,10 @@ export const get79 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdFilesContentResponse) export const content = { - get: get79, + get: get80, } -export const get80 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3763,7 +3781,7 @@ export const get80 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse) export const preview = { - get: get80, + get: get81, } export const patch5 = oc @@ -3826,7 +3844,7 @@ export const publish = { post: post54, } -export const get81 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3838,7 +3856,7 @@ export const get81 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse) export const references = { - get: get81, + get: get82, } export const post55 = oc @@ -3872,7 +3890,7 @@ export const delete15 = oc .input(z.object({ params: zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath })) .output(zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse) -export const get82 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3901,11 +3919,11 @@ export const patch6 = oc export const byVersionId = { delete: delete15, - get: get82, + get: get83, patch: patch6, } -export const get83 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3917,7 +3935,7 @@ export const get83 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse) export const versions = { - get: get83, + get: get84, byVersionId, } @@ -3937,7 +3955,7 @@ export const delete16 = oc ) .output(zDeleteWorkspacesCurrentSkillsBySkillIdResponse) -export const get84 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3966,7 +3984,7 @@ export const patch7 = oc export const bySkillId = { delete: delete16, - get: get84, + get: get85, patch: patch7, assist, duplicate, @@ -3978,7 +3996,7 @@ export const bySkillId = { versions, } -export const get85 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4002,7 +4020,7 @@ export const post56 = oc .output(zPostWorkspacesCurrentSkillsResponse) export const skills2 = { - get: get85, + get: get86, post: post56, files, import: import_, @@ -4010,7 +4028,7 @@ export const skills2 = { bySkillId, } -export const get86 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4021,10 +4039,10 @@ export const get86 = oc .output(zGetWorkspacesCurrentSummaryResponse) export const summary2 = { - get: get86, + get: get87, } -export const get87 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4035,7 +4053,7 @@ export const get87 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get87, + get: get88, } export const post57 = oc @@ -4068,7 +4086,7 @@ export const delete17 = { post: post58, } -export const get88 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4079,11 +4097,11 @@ export const get88 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get89 = { - get: get88, +export const get90 = { + get: get89, } -export const get90 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4095,7 +4113,7 @@ export const get90 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get90, + get: get91, } export const post59 = oc @@ -4132,7 +4150,7 @@ export const test = { pre, } -export const get91 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4144,7 +4162,7 @@ export const get91 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get91, + get: get92, } export const post61 = oc @@ -4165,7 +4183,7 @@ export const update2 = { export const api = { add, delete: delete17, - get: get89, + get: get90, remote, schema, test, @@ -4193,7 +4211,7 @@ export const add2 = { post: post62, } -export const get92 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4210,10 +4228,10 @@ export const get92 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get92, + get: get93, } -export const get93 = oc +export const get94 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4233,7 +4251,7 @@ export const get93 = oc ) export const byCredentialType = { - get: get93, + get: get94, } export const schema2 = { @@ -4245,7 +4263,7 @@ export const credential = { schema: schema2, } -export const get94 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4262,7 +4280,7 @@ export const get94 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get94, + get: get95, } export const post63 = oc @@ -4305,7 +4323,7 @@ export const delete18 = { post: post64, } -export const get95 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4317,10 +4335,10 @@ export const get95 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get95, + get: get96, } -export const get96 = oc +export const get97 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4332,10 +4350,10 @@ export const get96 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get96, + get: get97, } -export const get97 = oc +export const get98 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4349,7 +4367,7 @@ export const get97 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get97, + get: get98, } export const delete19 = oc @@ -4367,7 +4385,7 @@ export const delete19 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get98 = oc +export const get99 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4398,7 +4416,7 @@ export const post65 = oc export const customClient = { delete: delete19, - get: get98, + get: get99, post: post65, } @@ -4407,7 +4425,7 @@ export const oauth = { customClient, } -export const get99 = oc +export const get100 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4419,7 +4437,7 @@ export const get99 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get99, + get: get100, } export const post66 = oc @@ -4474,7 +4492,7 @@ export const auth = { post: post67, } -export const get100 = oc +export const get101 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4486,14 +4504,14 @@ export const get100 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get100, + get: get101, } export const tools3 = { byProviderId, } -export const get101 = oc +export const get102 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4505,7 +4523,7 @@ export const get101 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get101, + get: get102, } export const update4 = { @@ -4584,7 +4602,7 @@ export const delete21 = { post: post70, } -export const get102 = oc +export const get103 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4595,11 +4613,11 @@ export const get102 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get103 = { - get: get102, +export const get104 = { + get: get103, } -export const get104 = oc +export const get105 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4611,7 +4629,7 @@ export const get104 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get104, + get: get105, } export const post71 = oc @@ -4632,7 +4650,7 @@ export const update5 = { export const workflow = { create: create2, delete: delete21, - get: get103, + get: get104, tools: tools4, update: update5, } @@ -4644,7 +4662,7 @@ export const toolProvider = { workflow, } -export const get105 = oc +export const get106 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4656,10 +4674,10 @@ export const get105 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get105, + get: get106, } -export const get106 = oc +export const get107 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4670,10 +4688,10 @@ export const get106 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get106, + get: get107, } -export const get107 = oc +export const get108 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4684,10 +4702,10 @@ export const get107 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get107, + get: get108, } -export const get108 = oc +export const get109 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4698,10 +4716,10 @@ export const get108 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get108, + get: get109, } -export const get109 = oc +export const get110 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4712,7 +4730,7 @@ export const get109 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get109, + get: get110, } export const tools5 = { @@ -4722,7 +4740,7 @@ export const tools5 = { workflow: workflow2, } -export const get110 = oc +export const get111 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4734,13 +4752,13 @@ export const get110 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get110, + get: get111, } /** * Get info for a trigger provider */ -export const get111 = oc +export const get112 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4753,7 +4771,7 @@ export const get111 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get111, + get: get112, } /** @@ -4774,7 +4792,7 @@ export const delete22 = oc /** * Get OAuth client configuration for a provider */ -export const get112 = oc +export const get113 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4808,7 +4826,7 @@ export const post72 = oc export const client = { delete: delete22, - get: get112, + get: get113, post: post72, } @@ -4875,7 +4893,7 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider */ -export const get113 = oc +export const get114 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4896,7 +4914,7 @@ export const get113 = oc ) export const bySubscriptionBuilderId2 = { - get: get113, + get: get114, } export const logs = { @@ -4970,7 +4988,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get114 = oc +export const get115 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4991,7 +5009,7 @@ export const get114 = oc ) export const bySubscriptionBuilderId5 = { - get: get114, + get: get115, } export const builder = { @@ -5006,7 +5024,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get115 = oc +export const get116 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5019,13 +5037,13 @@ export const get115 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get115, + get: get116, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get116 = oc +export const get117 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5042,7 +5060,7 @@ export const get116 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get116, + get: get117, } export const oauth3 = { @@ -5159,7 +5177,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get117 = oc +export const get118 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5171,7 +5189,7 @@ export const get117 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get117, + get: get118, } export const current = { @@ -5219,7 +5237,7 @@ export const webappLogo = { upload: upload3, } -export const get118 = oc +export const get119 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5241,7 +5259,7 @@ export const post81 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - get: get118, + get: get119, post: post81, webappLogo, } @@ -5276,7 +5294,7 @@ export const switch3 = { post: post83, } -export const get119 = oc +export const get120 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5288,7 +5306,7 @@ export const get119 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get119, + get: get120, } export const byIconType = { @@ -5307,7 +5325,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get120 = oc +export const get121 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5318,7 +5336,7 @@ export const get120 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get120, + get: get121, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 369370f63e0e50..c332d99398de7b 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -401,6 +401,12 @@ export type NetworkAccessGroupUpdatePayload = { name: string } +export type NetworkAccessGroupCurrentIpCheckResponse = { + allowed: boolean + client_ip: string + policy_version: number +} + export type WorkspacePermissionResponse = { allow_member_invite: boolean allow_owner_transfer: boolean @@ -1538,6 +1544,7 @@ export type NetworkAccessGroupResponse = { apps: Array created_at: string description?: string + enforcing_count: number id: string name: string tenant_id: string @@ -2238,10 +2245,12 @@ export type ProviderModelWithStatusEntity = { export type CustomConfigurationStatus = 'active' | 'no-configure' export type NetworkAccessGroupAppResponse = { + bound_agent_id?: string | null icon?: string | null icon_background?: string | null icon_type?: string | null id: string + mode: string name: string } @@ -3989,6 +3998,22 @@ export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpData = { + body?: never + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}/check-current-ip' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses = { + 200: NetworkAccessGroupCurrentIpCheckResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses] + export type GetWorkspacesCurrentPermissionData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 5763871f480b94..53b4a896f21d77 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -280,6 +280,15 @@ export const zNetworkAccessGroupUpdatePayload = z.object({ name: z.string().min(1).max(100), }) +/** + * NetworkAccessGroupCurrentIPCheckResponse + */ +export const zNetworkAccessGroupCurrentIpCheckResponse = z.object({ + allowed: z.boolean(), + client_ip: z.string(), + policy_version: z.int().gte(1), +}) + /** * WorkspacePermissionResponse */ @@ -2335,10 +2344,12 @@ export const zModelProviderSummaryListResponse = z.object({ * NetworkAccessGroupAppResponse */ export const zNetworkAccessGroupAppResponse = z.object({ + bound_agent_id: z.string().nullish(), icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + mode: z.string(), name: z.string(), }) @@ -2351,6 +2362,7 @@ export const zNetworkAccessGroupResponse = z.object({ apps: z.array(zNetworkAccessGroupAppResponse), created_at: z.iso.datetime(), description: z.string().optional().default(''), + enforcing_count: z.int().gte(0), id: z.string(), name: z.string(), tenant_id: z.string(), @@ -4695,6 +4707,16 @@ export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = zNetworkAccessGroupMutationResponse +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath = z.object({ + group_id: z.uuid(), +}) + +/** + * Current client IP checked against the workspace network access group + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse = + zNetworkAccessGroupCurrentIpCheckResponse + /** * Success */ diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index 9fc8014774657e..a57e5418f94d2f 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -155,11 +155,19 @@ describe('IpPoliciesPage', () => { { id: 'app-a', name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-b', + name: 'Helpdesk', + mode: 'chat', icon: null, icon_type: null, icon_background: null, }, - { id: 'app-b', name: 'Helpdesk', icon: null, icon_type: null, icon_background: null }, ], }), ], @@ -194,11 +202,19 @@ describe('IpPoliciesPage', () => { { id: 'app-a', name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-b', + name: 'Helpdesk', + mode: 'chat', icon: null, icon_type: null, icon_background: null, }, - { id: 'app-b', name: 'Helpdesk', icon: null, icon_type: null, icon_background: null }, ], }), ], diff --git a/web/test/console/network-access.ts b/web/test/console/network-access.ts index 3c125e0c0eaed3..a611e14b28493f 100644 --- a/web/test/console/network-access.ts +++ b/web/test/console/network-access.ts @@ -18,6 +18,7 @@ export function createNetworkAccessGroupFixture( app_ids: [], apps: [], used_by_count: 0, + enforcing_count: 0, version: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', @@ -52,6 +53,7 @@ export function seedAppNetworkAccessGroup( tenant_id: 'workspace-1', app_id: appId, entitled: true, + effective_enabled: false, available_access_points: ['webapp', 'service_api', 'mcp'], binding: null, ...overrides, From d2de3fe533d5ee66c765e0725eb15e2170acccf4 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 11 Sep 2026 17:13:32 +0800 Subject: [PATCH 026/108] chore: satisfy network access CI style checks --- .../workspace/test_network_access_group.py | 7 ++++++- .../core/network_access/test_client_ip.py | 18 +++++++++--------- .../test_network_access_group_repository.py | 2 +- .../ip-policies-page/policy-dialog.tsx | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 92b762860888e4..436ea98740f0f2 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -406,7 +406,12 @@ def test_current_ip_check_uses_trusted_resolver_after_service_admission_and_disa service = MagicMock() config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") - def check_current_ip(_context, *, group_id, client_ip_supplier): + def check_current_ip( + _context: RequestContext, + *, + group_id: str, + client_ip_supplier: Callable[[], str], + ) -> dict[str, object]: assert group_id == GROUP_ID return {"client_ip": client_ip_supplier(), "allowed": True, "policy_version": 4} diff --git a/api/tests/unit_tests/core/network_access/test_client_ip.py b/api/tests/unit_tests/core/network_access/test_client_ip.py index f10f812eacf850..b876f5b0a82de4 100644 --- a/api/tests/unit_tests/core/network_access/test_client_ip.py +++ b/api/tests/unit_tests/core/network_access/test_client_ip.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize("config", ["", TRUSTED]) -def test_untrusted_socket_ignores_all_forwarding_headers(config: str): +def test_untrusted_socket_ignores_all_forwarding_headers(config: str) -> None: assert ( resolve_network_access_client_ip( { @@ -31,7 +31,7 @@ def test_untrusted_socket_ignores_all_forwarding_headers(config: str): (" 2001:0db8:0:0::1234 , 10.2.3.4 ", "2001:db8::1234"), ], ) -def test_trusted_chain_strips_only_rightmost_trusted_hops(header: str, expected: str): +def test_trusted_chain_strips_only_rightmost_trusted_hops(header: str, expected: str) -> None: assert ( resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) == expected @@ -55,18 +55,18 @@ def test_trusted_chain_strips_only_rightmost_trusted_hops(header: str, expected: "x" * 2049, ], ) -def test_trusted_peer_rejects_missing_invalid_or_all_trusted_chains(header: object): +def test_trusted_peer_rejects_missing_invalid_or_all_trusted_chains(header: object) -> None: with pytest.raises(NetworkAccessClientIPUnavailableError): resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) @pytest.mark.parametrize("peer", [None, "", "unknown", "[::1]", "203.0.113.42:5000", "fe80::1%eth0", 1]) -def test_invalid_socket_peer_is_unavailable(peer: object): +def test_invalid_socket_peer_is_unavailable(peer: object) -> None: with pytest.raises(NetworkAccessClientIPUnavailableError): resolve_network_access_client_ip({"REMOTE_ADDR": peer}, TRUSTED) -def test_proxy_fix_cannot_make_untrusted_origin_appear_trusted(): +def test_proxy_fix_cannot_make_untrusted_origin_appear_trusted() -> None: environ = { "REMOTE_ADDR": "172.18.0.2", "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "203.0.113.42"}, @@ -75,7 +75,7 @@ def test_proxy_fix_cannot_make_untrusted_origin_appear_trusted(): assert resolve_network_access_client_ip(environ, TRUSTED) == "203.0.113.42" -def test_proxy_fix_preserves_trusted_socket_even_after_remote_addr_rewrite(): +def test_proxy_fix_preserves_trusted_socket_even_after_remote_addr_rewrite() -> None: environ = { "REMOTE_ADDR": "192.0.2.1", "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "172.18.0.2"}, @@ -85,7 +85,7 @@ def test_proxy_fix_preserves_trusted_socket_even_after_remote_addr_rewrite(): @pytest.mark.parametrize("original", ["invalid", {}, {"REMOTE_ADDR": None}]) -def test_malformed_proxy_fix_original_is_unavailable(original: object): +def test_malformed_proxy_fix_original_is_unavailable(original: object) -> None: with pytest.raises(NetworkAccessClientIPUnavailableError): resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42", "werkzeug.proxy_fix.orig": original}, TRUSTED) @@ -93,12 +93,12 @@ def test_malformed_proxy_fix_original_is_unavailable(original: object): @pytest.mark.parametrize( "config", ["not-a-cidr", "172.18.0.1/16", "172.18.0.0/16,", "fe80::%eth0/64", "10.0.0.0/8," * 257] ) -def test_invalid_proxy_configuration_is_unavailable(config: str): +def test_invalid_proxy_configuration_is_unavailable(config: str) -> None: with pytest.raises(NetworkAccessClientIPUnavailableError): resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42"}, config) -def test_mapped_socket_and_proxy_cidr_match_ipv4(): +def test_mapped_socket_and_proxy_cidr_match_ipv4() -> None: assert ( resolve_network_access_client_ip( {"REMOTE_ADDR": "::ffff:172.18.0.2", "HTTP_X_FORWARDED_FOR": "::ffff:203.0.113.42"}, diff --git a/api/tests/unit_tests/repositories/test_network_access_group_repository.py b/api/tests/unit_tests/repositories/test_network_access_group_repository.py index 59b1f3e92a8705..0e62ebf17b5bc0 100644 --- a/api/tests/unit_tests/repositories/test_network_access_group_repository.py +++ b/api/tests/unit_tests/repositories/test_network_access_group_repository.py @@ -219,7 +219,7 @@ def test_list_apps_batch_loads_agent_routes_and_excludes_hidden_workflow_backing agent_selects: list[str] = [] - def record_agent_select(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + def record_agent_select(_conn, _cursor, statement: str, _parameters, _context, _executemany) -> None: if "FROM agents" in statement: agent_selects.append(statement) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index ab794d9cfb01ff..1db4de9ad53d2c 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -23,7 +23,7 @@ import { IP_POLICY_NAME_MAX_LENGTH, } from './validate-ip-entry' -export type IpPolicyDialogSubmit = { +type IpPolicyDialogSubmit = { name: string allowed_cidrs: string[] } From 7c95008ab7f9c2a29b3b121f2e79350d31dd7724 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 11 Sep 2026 17:16:32 +0800 Subject: [PATCH 027/108] refactor: reuse network access point conversion --- .../app/access-point/access-control/network-access.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/app/access-point/access-control/network-access.ts b/web/app/components/app/access-point/access-control/network-access.ts index df273ca56d670a..7331cbdb779037 100644 --- a/web/app/components/app/access-point/access-control/network-access.ts +++ b/web/app/components/app/access-point/access-control/network-access.ts @@ -38,7 +38,7 @@ export function availabilityFromAccessPoints( const allowed = new Set( availableAccessPoints.flatMap((accessPoint) => { if (accessPoint in API_TO_ACCESS_POINT) - return [API_TO_ACCESS_POINT[accessPoint as NetworkAccessPoint]] + return [toUiAccessPoint(accessPoint as NetworkAccessPoint)] return [] }), ) @@ -73,7 +73,7 @@ export function scopesFromAccessPoints( const selected = new Set( accessPoints.flatMap((accessPoint) => { if (accessPoint in API_TO_ACCESS_POINT) - return [API_TO_ACCESS_POINT[accessPoint as NetworkAccessPoint]] + return [toUiAccessPoint(accessPoint as NetworkAccessPoint)] return [] }), ) From 1fdbbf479a40461a3cc9f2042f50412d0fc0b445 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 11 Sep 2026 19:03:02 +0800 Subject: [PATCH 028/108] fix(web): create IP policies in-place and keep apply-to independent Open the New IP Policy dialog from Access Control instead of full Settings. Keep all four apply-to toggles selectable, drop Not enabled coupling with app-supported surfaces, and still persist only backend-accepted access points. --- .../__tests__/chip-status.spec.ts | 40 +-- .../__tests__/config-panel.spec.tsx | 78 ++---- .../access-control/__tests__/draft.spec.ts | 107 +------- .../access-control/__tests__/index.spec.tsx | 118 ++++++++- .../__tests__/network-access.spec.ts | 78 +++--- .../access-control/chip-status.ts | 21 +- .../access-control/config-panel.tsx | 28 +-- .../app/access-point/access-control/draft.ts | 70 +----- .../app/access-point/access-control/index.tsx | 237 ++++++++++-------- .../access-control/network-access.ts | 48 ++-- .../access-control/scope-list.tsx | 36 +-- .../access-control/status-panel.tsx | 31 +-- 12 files changed, 383 insertions(+), 509 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts index 8b0bb0ce6daaae..d9d35ac7d01e75 100644 --- a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts @@ -1,12 +1,5 @@ import { getAccessControlChipState, getInServiceCoverage } from '../chip-status' -const availability = { - webApp: true, - serviceApi: true, - mcp: true, - trigger: false, -} - const assignment = { policyId: 'internal-network', policyName: 'Internal Network', @@ -14,19 +7,16 @@ const assignment = { webApp: true, serviceApi: true, mcp: true, - trigger: false, + trigger: true, }, enabled: true, } describe('getInServiceCoverage', () => { - it('ignores unavailable access points in the denominator', () => { + it('counts selected access points against every configured access point', () => { expect( - getInServiceCoverage( - { webApp: true, serviceApi: true, mcp: false, trigger: true }, - availability, - ), - ).toEqual({ coveredCount: 2, inServiceCount: 3 }) + getInServiceCoverage({ webApp: true, serviceApi: true, mcp: false, trigger: true }), + ).toEqual({ coveredCount: 3, inServiceCount: 4 }) }) }) @@ -36,7 +26,6 @@ describe('getAccessControlChipState', () => { getAccessControlChipState({ entitled: false, assignment: null, - availability, }).kind, ).toBe('pro') }) @@ -46,7 +35,6 @@ describe('getAccessControlChipState', () => { getAccessControlChipState({ entitled: false, assignment, - availability, }).kind, ).toBe('on') }) @@ -56,7 +44,6 @@ describe('getAccessControlChipState', () => { getAccessControlChipState({ entitled: true, assignment: null, - availability, }), ).toEqual({ kind: 'off', coveredCount: 0, inServiceCount: 0 }) }) @@ -66,32 +53,30 @@ describe('getAccessControlChipState', () => { getAccessControlChipState({ entitled: true, assignment: { ...assignment, enabled: false }, - availability, }), ).toEqual({ kind: 'paused', policyName: 'Internal Network', - coveredCount: 3, - inServiceCount: 3, + coveredCount: 4, + inServiceCount: 4, }) }) - it('returns on when every in-service access point is covered', () => { + it('returns on when every access point is covered', () => { expect( getAccessControlChipState({ entitled: true, assignment, - availability, }), ).toEqual({ kind: 'on', policyName: 'Internal Network', - coveredCount: 3, - inServiceCount: 3, + coveredCount: 4, + inServiceCount: 4, }) }) - it('returns partial when some in-service access points are excluded', () => { + it('returns partial when some access points are excluded', () => { expect( getAccessControlChipState({ entitled: true, @@ -99,13 +84,12 @@ describe('getAccessControlChipState', () => { ...assignment, scopes: { ...assignment.scopes, mcp: false }, }, - availability, }), ).toEqual({ kind: 'partial', policyName: 'Internal Network', - coveredCount: 2, - inServiceCount: 3, + coveredCount: 3, + inServiceCount: 4, }) }) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index 3f210e7edbb2b5..30195b987e4ad7 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -1,8 +1,4 @@ -import type { - AccessControlDraft, - AccessControlPolicy, - AccessControlScopeAvailability, -} from '../draft' +import type { AccessControlDraft, AccessControlPolicy } from '../draft' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -11,13 +7,6 @@ import { render } from '@/test/console/render' import { AccessControlConfigPanel } from '../config-panel' import { createDefaultAccessControlDraft } from '../draft' -const allScopesAvailable: AccessControlScopeAvailability = { - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, -} - const policies: AccessControlPolicy[] = [ { id: 'internal-network', @@ -48,7 +37,6 @@ const translations = vi.hoisted(() => ({ 'studio.accessControl.lockoutWarning': "Your IP ({{ip}}) isn't in this policy. You may lose access.", 'studio.accessControl.manageIpPolicies': 'Manage IP policies', - 'studio.accessControl.notEnabled': 'Not enabled', 'studio.accessControl.policySummaryMany': 'Allows {{listed}} and {{count}} more addresses', 'studio.accessControl.policySummaryOne': 'Allows {{address}}', 'studio.accessControl.selectAccessPoint': 'Select at least one access point to protect.', @@ -61,12 +49,10 @@ vi.mock('react-i18next', async () => { }) function PanelHarness({ - availability = allScopesAvailable, currentIp, initialDraft = createDefaultAccessControlDraft(), onSave = vi.fn(), }: { - availability?: AccessControlScopeAvailability currentIp?: string initialDraft?: AccessControlDraft onSave?: () => void @@ -84,7 +70,6 @@ function PanelHarness({ draft={draft} policies={policies} currentIp={currentIp} - availability={availability} onCancel={onCancel} onCreatePolicy={onCreatePolicy} onManagePolicies={onManagePolicies} @@ -102,7 +87,22 @@ describe('AccessControlConfigPanel', () => { expect(await screen.findByRole('option', { name: /Internal Network/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() - expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) }) it('enables save after a policy is selected', async () => { @@ -125,7 +125,7 @@ describe('AccessControlConfigPanel', () => { expect(onSave).toHaveBeenCalledTimes(1) }) - it('warns and disables save when every protectable access point is off', async () => { + it('warns and disables save when every access point is off', async () => { const user = userEvent.setup() render() @@ -133,6 +133,7 @@ describe('AccessControlConfigPanel', () => { await user.click(screen.getByRole('switch', { name: 'Web App' })) await user.click(screen.getByRole('switch', { name: 'Backend Service API' })) await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + await user.click(screen.getByRole('switch', { name: 'Trigger' })) expect(screen.getByText('Select at least one access point to protect.')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() @@ -150,26 +151,8 @@ describe('AccessControlConfigPanel', () => { expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() }) - it('marks an unavailable MCP server as not enabled', () => { - render( - , - ) - - expect(screen.getByText('Not enabled')).toBeInTheDocument() - expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( - 'aria-disabled', - 'true', - ) - }) - - it('disables MCP and Trigger for agent apps', () => { + it('lets the user turn any access point off without a Not enabled label', async () => { + const user = userEvent.setup() render( { selectedPolicyId: 'internal-network', enabled: true, }} - availability={{ webApp: true, serviceApi: true, mcp: false, trigger: false }} />, ) - expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'Trigger' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.queryByText('Not enabled')).not.toBeInTheDocument() + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() + await user.click(screen.getByRole('switch', { name: 'Trigger' })) + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() }) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts index cbf7652361d862..3510c0d5e9f7b9 100644 --- a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts @@ -2,20 +2,7 @@ import { policyIncludesIp, splitPolicySummary, } from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' -import { AppModeEnum } from '@/types/app' -import { - canSaveAccessControl, - createDefaultAccessControlDraft, - getAccessControlScopeAvailability, - getAccessControlScopeSupport, -} from '../draft' - -const allScopesAvailable = { - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, -} +import { canSaveAccessControl, createDefaultAccessControlDraft } from '../draft' const selectedDraft = { ...createDefaultAccessControlDraft(), @@ -24,15 +11,10 @@ const selectedDraft = { describe('canSaveAccessControl', () => { it('disables save when no policy is selected', () => { - expect( - canSaveAccessControl({ - draft: createDefaultAccessControlDraft(), - availability: { ...allScopesAvailable, trigger: false }, - }), - ).toBe(false) + expect(canSaveAccessControl({ draft: createDefaultAccessControlDraft() })).toBe(false) }) - it('disables save when every protectable access point is off', () => { + it('disables save when every access point is off', () => { expect( canSaveAccessControl({ draft: { @@ -42,24 +24,18 @@ describe('canSaveAccessControl', () => { webApp: false, serviceApi: false, mcp: false, - trigger: true, + trigger: false, }, }, - availability: { ...allScopesAvailable, trigger: false }, }), ).toBe(false) }) it('enables save when a policy is selected and at least one access point is on', () => { - expect( - canSaveAccessControl({ - draft: selectedDraft, - availability: { ...allScopesAvailable, trigger: false }, - }), - ).toBe(true) + expect(canSaveAccessControl({ draft: selectedDraft })).toBe(true) }) - it('counts an enabled trigger only when that access point is available', () => { + it('counts trigger as a selectable access point', () => { expect( canSaveAccessControl({ draft: { @@ -72,12 +48,11 @@ describe('canSaveAccessControl', () => { trigger: true, }, }, - availability: allScopesAvailable, }), ).toBe(true) }) - it('does not count an unavailable MCP server toward save', () => { + it('does not enable save when none of the selected access points can be persisted', () => { expect( canSaveAccessControl({ draft: { @@ -86,11 +61,11 @@ describe('canSaveAccessControl', () => { scopes: { webApp: false, serviceApi: false, - mcp: true, - trigger: false, + mcp: false, + trigger: true, }, }, - availability: { ...allScopesAvailable, mcp: false, trigger: false }, + persistableAccessPoints: [], }), ).toBe(false) }) @@ -99,7 +74,6 @@ describe('canSaveAccessControl', () => { expect( canSaveAccessControl({ draft: selectedDraft, - availability: { ...allScopesAvailable, trigger: false }, baseline: selectedDraft, }), ).toBe(false) @@ -109,73 +83,12 @@ describe('canSaveAccessControl', () => { expect( canSaveAccessControl({ draft: { ...selectedDraft, enabled: false }, - availability: { ...allScopesAvailable, trigger: false }, baseline: selectedDraft, }), ).toBe(true) }) }) -describe('getAccessControlScopeSupport', () => { - it('lets workflow apps apply to every access point', () => { - expect(getAccessControlScopeSupport(AppModeEnum.WORKFLOW)).toEqual({ - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, - }) - }) - - it('lets agent apps apply only to Web App and Backend Service API', () => { - expect(getAccessControlScopeSupport(AppModeEnum.AGENT)).toEqual({ - webApp: true, - serviceApi: true, - mcp: false, - trigger: false, - }) - }) - - it.each([ - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.CHAT, - AppModeEnum.COMPLETION, - AppModeEnum.AGENT_CHAT, - ])('does not let %s apps apply to Trigger', (mode) => { - expect(getAccessControlScopeSupport(mode)).toEqual({ - webApp: true, - serviceApi: true, - mcp: true, - trigger: false, - }) - }) -}) - -describe('getAccessControlScopeAvailability', () => { - it('keeps workflow Trigger off until a published trigger node exists', () => { - expect( - getAccessControlScopeAvailability({ - mode: AppModeEnum.WORKFLOW, - hasTriggerNode: false, - isUnpublished: false, - }).trigger, - ).toBe(false) - expect( - getAccessControlScopeAvailability({ - mode: AppModeEnum.WORKFLOW, - hasTriggerNode: true, - isUnpublished: true, - }).trigger, - ).toBe(false) - expect( - getAccessControlScopeAvailability({ - mode: AppModeEnum.WORKFLOW, - hasTriggerNode: true, - isUnpublished: false, - }).trigger, - ).toBe(true) - }) -}) - describe('splitPolicySummary', () => { it('keeps one or two addresses listed', () => { expect(splitPolicySummary(['203.0.113.42'])).toEqual({ diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 12f9950c63d5a6..cee1e530a801b0 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -15,13 +15,28 @@ const mockSetPricing = vi.fn() const mockSetSettingsDestination = vi.fn() const accessControlTranslations = vi.hoisted(() => ({ 'operation.cancel': 'Cancel', + 'operation.close': 'Close', 'operation.save': 'Save', 'overview.apiInfo.title': 'Backend Service API', 'overview.appInfo.title': 'Web App', 'mcp.server.title': 'MCP Server', + 'settings.ipPolicies': 'IP Policies', + 'settings.ipPolicyAddEntry': 'Add', + 'settings.ipPolicyAllowlist': 'Allowlist', + 'settings.ipPolicyAllowlistHelp': + 'Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.', + 'settings.ipPolicyCreate': 'Create', + 'settings.ipPolicyDialogDescription': + 'Specify which IP addresses or ranges can access your apps.', + 'settings.ipPolicyName': 'Name', + 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', + 'settings.ipPolicyNewTitle': 'New IP Policy', + 'settings.ipPolicyRemoveEntry': 'Remove entry', 'settings.trigger': 'Trigger', + 'studio.accessControl.addressCount': '{{count}} addresses', 'studio.accessControl.applyTo': 'Apply to', 'studio.accessControl.applyToHelp': 'Choose which access points to protect.', + 'studio.accessControl.applyToHelpSelected': 'Choose which access points this policy protects.', 'studio.accessControl.createIpPolicy': 'Create an IP policy', 'studio.accessControl.emptyPoliciesDescription': 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', @@ -31,19 +46,24 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.chipPartial': '{{n}} of {{m}}', 'studio.accessControl.entryLabel': 'Access Control', 'studio.accessControl.ipPolicy': 'IP Policy', + 'studio.accessControl.manageIpPolicies': 'Manage IP policies', 'studio.accessControl.notEnabled': 'Not enabled', 'studio.accessControl.paywallDescription': 'Restrict this app to IP addresses you trust.', 'studio.accessControl.paywallTitle': 'Access Control', + 'studio.accessControl.policySummaryOne': 'Allows {{address}}', 'studio.accessControl.previewAppName': 'Code Companion', 'studio.accessControl.previewCaption': "This app is only available on your organization's network.", 'studio.accessControl.proBadge': 'PRO', + 'studio.accessControl.selectPolicy': 'Select a policy', 'studio.accessControl.tooltipOff': 'Not set up', 'studio.accessControl.tooltipPro': 'Access control requires the Pro plan', 'studio.accessControl.turnOn': 'Turn on Access Control', 'studio.accessControl.restrictByIp': 'Restrict by IP address', 'studio.accessControl.restrictedTo': 'Restricted to {{name}}', + 'studio.accessControl.excluded': 'Excluded', 'studio.accessControl.protectingAll': 'Protecting all {{count}} access points in service.', + 'studio.accessControl.protectingPartial': 'Protecting {{n}} of {{m}} access points in service.', 'studio.accessControl.policySummaryTwo': 'Allows {{first}} and {{second}}', 'operation.edit': 'Edit', 'studio.accessControl.turnOffTitle': 'Turn off access control?', @@ -143,7 +163,7 @@ describe('AccessControlEntry', () => { }, }) - expect(within(getChip()).getByText('ON')).toBeInTheDocument() + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() await user.click(getChip()) expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Turn on Access Control' })).toBeInTheDocument() @@ -221,30 +241,106 @@ describe('AccessControlEntry', () => { ).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() - expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByRole('switch', { name: 'Backend Service API' })).toHaveAttribute( + expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( 'aria-disabled', 'true', ) - expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( + expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( 'aria-disabled', 'true', ) - expect(screen.getByRole('switch', { name: 'Trigger' })).toHaveAttribute('aria-disabled', 'true') - expect(screen.getByText('Not enabled')).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.queryByText('Not enabled')).not.toBeInTheDocument() }) - it('sends paid users to Settings IP Policies from the empty-state exit', async () => { + it('opens the new policy dialog from the empty-state create action', async () => { const user = userEvent.setup() renderEntry({ plan: 'professional' }) await user.click(getChip()) await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) - expect(mockSetSettingsDestination).toHaveBeenCalledWith('ip-policies') + expect(screen.getByRole('heading', { name: 'New IP Policy' })).toBeInTheDocument() + expect(mockSetSettingsDestination).not.toHaveBeenCalled() + }) + + it('returns to the empty-state config after canceling the new policy dialog', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })) + await waitFor(() => { - expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'New IP Policy' })).not.toBeInTheDocument() }) + expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() + expect(mockSetSettingsDestination).not.toHaveBeenCalled() + }) + + it('posts a new policy from the empty-state dialog without opening Settings', async () => { + const user = userEvent.setup() + const created = createNetworkAccessGroupFixture({ + id: 'group-office', + name: 'Office', + allowed_cidrs: ['10.0.0.0/8'], + }) + const posted: Array<{ method: string; url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + const bodyText = + request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text() + posted.push({ + method: request.method, + url: request.url, + body: bodyText ? JSON.parse(bodyText) : null, + }) + return new Response(JSON.stringify({ group: created }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }) + }) + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Office') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + await user.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(posted).toContainEqual({ + method: 'POST', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups', + body: { + name: 'Office', + description: '', + allowed_cidrs: ['10.0.0.0/8'], + }, + }) + }) + expect(mockSetSettingsDestination).not.toHaveBeenCalled() + }) + + it('sends paid users to Settings IP Policies from manage', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Manage IP policies' })) + + expect(mockSetSettingsDestination).toHaveBeenCalledWith('ip-policies') }) it('closes the first-time config on Cancel without opening settings', async () => { @@ -279,7 +375,7 @@ describe('AccessControlEntry', () => { }, }) - expect(within(getChip()).getByText('ON')).toBeInTheDocument() + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() await user.click(getChip()) expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toBeChecked() @@ -308,6 +404,6 @@ describe('AccessControlEntry', () => { await user.click(screen.getByRole('button', { name: 'Turn off' })) expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('ON')).toBeInTheDocument() + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() }) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts index 9856b34c8439ac..63c51d27717d6b 100644 --- a/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts @@ -1,6 +1,5 @@ import { accessPointsFromScopes, - availabilityFromAccessPoints, draftFromBinding, getNetworkAccessErrorStatus, scopesFromAccessPoints, @@ -8,13 +7,6 @@ import { toUiAccessPoint, } from '../network-access' -const allAvailable = { - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, -} - describe('network access mappers', () => { it('maps UI access points to the Console contract', () => { expect(toApiAccessPoint('webApp')).toBe('webapp') @@ -23,30 +15,13 @@ describe('network access mappers', () => { expect(toUiAccessPoint('service_api')).toBe('serviceApi') }) - it('builds availability from the server access-point matrix', () => { - expect(availabilityFromAccessPoints(['webapp', 'service_api', 'mcp'], true)).toEqual({ - webApp: true, - serviceApi: true, - mcp: true, - trigger: false, - }) - expect( - availabilityFromAccessPoints(['webapp', 'service_api', 'mcp', 'trigger'], false), - ).toEqual({ - webApp: true, - serviceApi: true, - mcp: true, - trigger: false, - }) - }) - it('reads HTTP status from Response and nested response objects', () => { expect(getNetworkAccessErrorStatus(new Response(null, { status: 409 }))).toBe(409) expect(getNetworkAccessErrorStatus({ response: { status: 409 } })).toBe(409) }) - it('builds scopes from selected API access points and available ones', () => { - expect(scopesFromAccessPoints(['webapp', 'mcp'], allAvailable)).toEqual({ + it('builds scopes from selected API access points', () => { + expect(scopesFromAccessPoints(['webapp', 'mcp'])).toEqual({ webApp: true, serviceApi: false, mcp: true, @@ -54,17 +29,33 @@ describe('network access mappers', () => { }) }) - it('omits unavailable scopes when assembling a PUT payload', () => { + it('includes every selected access point in the PUT payload', () => { + expect( + accessPointsFromScopes({ + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, + }), + ).toEqual(['webapp', 'service_api', 'mcp', 'trigger']) + }) + + it('omits selected access points the backend does not accept for this app', () => { expect( accessPointsFromScopes( - { webApp: true, serviceApi: true, mcp: true, trigger: true }, - { ...allAvailable, trigger: false }, + { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, + }, + ['webapp', 'service_api', 'mcp'], ), ).toEqual(['webapp', 'service_api', 'mcp']) }) it('creates an empty draft when the app has no binding', () => { - expect(draftFromBinding(null, allAvailable)).toEqual({ + expect(draftFromBinding(null)).toEqual({ selectedPolicyId: null, enabled: true, scopes: { @@ -78,20 +69,17 @@ describe('network access mappers', () => { it('hydrates a draft from a bound group', () => { expect( - draftFromBinding( - { - id: 'binding-1', - tenant_id: 'workspace-1', - app_id: 'app-1', - enabled: true, - group_id: 'group-1', - access_points: ['webapp', 'service_api'], - version: 3, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - }, - allAvailable, - ), + draftFromBinding({ + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api'], + version: 3, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }), ).toEqual({ selectedPolicyId: 'group-1', enabled: true, diff --git a/web/app/components/app/access-point/access-control/chip-status.ts b/web/app/components/app/access-point/access-control/chip-status.ts index 557c050fe2d958..8833d78767e5a5 100644 --- a/web/app/components/app/access-point/access-control/chip-status.ts +++ b/web/app/components/app/access-point/access-control/chip-status.ts @@ -1,4 +1,4 @@ -import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessControlDraft } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' @@ -18,34 +18,28 @@ export type AccessControlChipState = { policyName?: string } -export function getInServiceCoverage( - scopes: Record, - availability: AccessControlScopeAvailability, -) { - const inService = ACCESS_POINT_ORDER.filter((scope) => availability[scope]) - const covered = inService.filter((scope) => scopes[scope]) +export function getInServiceCoverage(scopes: Record) { + const covered = ACCESS_POINT_ORDER.filter((scope) => scopes[scope]) return { coveredCount: covered.length, - inServiceCount: inService.length, + inServiceCount: ACCESS_POINT_ORDER.length, } } export function getAccessControlChipState({ entitled, assignment, - availability, }: { entitled?: boolean assignment: AccessControlAssignment | null - availability: AccessControlScopeAvailability }): AccessControlChipState { if (!assignment) { if (entitled === false) return { kind: 'pro', coveredCount: 0, inServiceCount: 0 } return { kind: 'off', coveredCount: 0, inServiceCount: 0 } } - const coverage = getInServiceCoverage(assignment.scopes, availability) + const coverage = getInServiceCoverage(assignment.scopes) if (!assignment.enabled) { return { @@ -56,10 +50,7 @@ export function getAccessControlChipState({ } return { - kind: - coverage.inServiceCount > 0 && coverage.coveredCount === coverage.inServiceCount - ? 'on' - : 'partial', + kind: coverage.coveredCount === coverage.inServiceCount ? 'on' : 'partial', policyName: assignment.policyName, ...coverage, } diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index 9ae1ac637d8c1f..b4c00ee2bcd49e 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -1,18 +1,14 @@ 'use client' import type { FormEvent } from 'react' -import type { - AccessControlDraft, - AccessControlPolicy, - AccessControlScopeAvailability, -} from './draft' +import type { AccessControlDraft, AccessControlPolicy } from './draft' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' import { IconButton } from '@langgenius/dify-ui/icon-button' import { PopoverTitle } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' -import { canSaveAccessControl, hasSelectedProtectableAccessPoint } from './draft' +import { canSaveAccessControl, hasSelectedAccessPoint } from './draft' import { AccessControlPolicyField } from './policy-field' import { AccessControlScopeList } from './scope-list' @@ -20,7 +16,7 @@ type AccessControlConfigPanelProps = { draft: AccessControlDraft policies: readonly AccessControlPolicy[] currentIp?: string - availability: AccessControlScopeAvailability + persistableAccessPoints?: readonly string[] baseline?: AccessControlDraft showBack?: boolean saving?: boolean @@ -36,7 +32,7 @@ export function AccessControlConfigPanel({ draft, policies, currentIp, - availability, + persistableAccessPoints, baseline, showBack = false, saving = false, @@ -50,7 +46,10 @@ export function AccessControlConfigPanel({ const { t } = useTranslation() const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const hasSelectedPolicy = Boolean(draft.selectedPolicyId) - const canSave = canSaveAccessControl({ draft, availability, baseline }) + const hasPersistableSelection = persistableAccessPoints + ? persistableAccessPoints.length > 0 + : hasSelectedAccessPoint(draft) + const canSave = canSaveAccessControl({ draft, baseline, persistableAccessPoints }) const handleSubmit = (event: FormEvent) => { event.preventDefault() @@ -99,7 +98,7 @@ export function AccessControlConfigPanel({ /> -
+
{t(($) => $['studio.accessControl.applyTo'], { ns: 'deployments' })} @@ -108,13 +107,8 @@ export function AccessControlConfigPanel({ ? t(($) => $['studio.accessControl.applyToHelpSelected'], { ns: 'deployments' }) : t(($) => $['studio.accessControl.applyToHelp'], { ns: 'deployments' })}

- - {hasSelectedPolicy && !hasSelectedProtectableAccessPoint(draft, availability) && ( + + {hasSelectedPolicy && draft.enabled && !hasPersistableSelection && (

{t(($) => $['studio.accessControl.selectAccessPoint'], { ns: 'deployments' })}

diff --git a/web/app/components/app/access-point/access-control/draft.ts b/web/app/components/app/access-point/access-control/draft.ts index 947f4b6119ca5f..a9bde917d5d467 100644 --- a/web/app/components/app/access-point/access-control/draft.ts +++ b/web/app/components/app/access-point/access-control/draft.ts @@ -1,9 +1,6 @@ import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' -import { AppModeEnum } from '@/types/app' - -export type AccessControlScopeAvailability = Record export type AccessControlPolicy = Pick @@ -13,57 +10,17 @@ export type AccessControlDraft = { enabled: boolean } -export const createDefaultAccessControlDraft = ( - availability?: AccessControlScopeAvailability, -): AccessControlDraft => ({ +export const createDefaultAccessControlDraft = (): AccessControlDraft => ({ selectedPolicyId: null, enabled: true, scopes: { - webApp: availability?.webApp ?? true, - serviceApi: availability?.serviceApi ?? true, - mcp: availability?.mcp ?? true, - trigger: availability?.trigger ?? false, + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, }, }) -export function getAccessControlScopeSupport( - mode: AppModeEnum | undefined, -): AccessControlScopeAvailability { - if (mode === AppModeEnum.AGENT) { - return { webApp: true, serviceApi: true, mcp: false, trigger: false } - } - - if (mode === AppModeEnum.WORKFLOW) { - return { webApp: true, serviceApi: true, mcp: true, trigger: true } - } - - return { webApp: true, serviceApi: true, mcp: true, trigger: false } -} - -export function getAccessControlScopeAvailability({ - mode, - hasTriggerNode, - isUnpublished, -}: { - mode: AppModeEnum | undefined - hasTriggerNode: boolean - isUnpublished: boolean -}): AccessControlScopeAvailability { - const support = getAccessControlScopeSupport(mode) - - return { - ...support, - trigger: support.trigger && !isUnpublished && hasTriggerNode, - } -} - -export function isProtectableAccessPoint( - scope: AccessPoint, - availability: AccessControlScopeAvailability, -) { - return availability[scope] -} - export function isAccessControlDraftEqual(left: AccessControlDraft, right: AccessControlDraft) { if (left.selectedPolicyId !== right.selectedPolicyId) return false if (left.enabled !== right.enabled) return false @@ -71,27 +28,22 @@ export function isAccessControlDraftEqual(left: AccessControlDraft, right: Acces return ACCESS_POINT_ORDER.every((scope) => left.scopes[scope] === right.scopes[scope]) } -export function hasSelectedProtectableAccessPoint( - draft: AccessControlDraft, - availability: AccessControlScopeAvailability, -) { - return ACCESS_POINT_ORDER.some((scope) => { - if (!isProtectableAccessPoint(scope, availability)) return false - return draft.scopes[scope] - }) +export function hasSelectedAccessPoint(draft: AccessControlDraft) { + return ACCESS_POINT_ORDER.some((scope) => draft.scopes[scope]) } export function canSaveAccessControl({ draft, - availability, baseline, + persistableAccessPoints, }: { draft: AccessControlDraft - availability: AccessControlScopeAvailability baseline?: AccessControlDraft + persistableAccessPoints?: readonly string[] }) { if (baseline && isAccessControlDraftEqual(draft, baseline)) return false if (!draft.enabled) return Boolean(draft.selectedPolicyId) if (!draft.selectedPolicyId) return false - return hasSelectedProtectableAccessPoint(draft, availability) + if (persistableAccessPoints) return persistableAccessPoints.length > 0 + return hasSelectedAccessPoint(draft) } diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index 11853025437772..b15b2a2fb6843a 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -14,6 +14,7 @@ import { pricingQueryParamName, pricingQueryParser, } from '@/app/components/billing/pricing/query-params' +import { IpPolicyDialog } from '@/app/components/header/account-setting/ip-policies-page/policy-dialog' import { settingsQueryParamName, settingsQueryParser, @@ -21,20 +22,13 @@ import { import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state' import { deploymentEditionAtom } from '@/features/system-features/state' import { consoleQuery } from '@/service/console' -import { useAppWorkflow } from '@/service/use-workflow' -import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' import { AccessControlChipAffix } from './chip-affix' import { getAccessControlChipState } from './chip-status' import { AccessControlConfigPanel } from './config-panel' -import { - canSaveAccessControl, - getAccessControlScopeAvailability, - isAccessControlDraftEqual, -} from './draft' +import { canSaveAccessControl, isAccessControlDraftEqual } from './draft' import { AccessControlFreePaywall } from './free-paywall' import { accessPointsFromScopes, - availabilityFromAccessPoints, draftFromBinding, getNetworkAccessErrorStatus, scopesFromAccessPoints, @@ -56,10 +50,6 @@ export function AccessControlEntry() { const appInfo = useAppStore((state) => state.appDetail) const appId = appInfo?.id const canFetchNetworkAccess = deploymentEdition === 'CLOUD' && isManager - const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) - const { data: workflow } = useAppWorkflow( - shouldFetchWorkflow && canFetchNetworkAccess && appInfo ? appInfo.id : '', - ) const groupsQuery = useQuery( consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ enabled: canFetchNetworkAccess, @@ -77,10 +67,14 @@ export function AccessControlEntry() { const updateBinding = useMutation( consoleQuery.apps.byAppId.networkAccessGroup.put.mutationOptions(), ) + const createGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.post.mutationOptions(), + ) const [open, setOpen] = useState(false) const [view, setView] = useState<'config' | 'status'>('status') const [showBack, setShowBack] = useState(false) const [draft, setDraft] = useState(null) + const [createPolicyOpen, setCreatePolicyOpen] = useState(false) const bindingErrorStatus = getNetworkAccessErrorStatus(bindingQuery.error) const hideForUnsupportedApp = @@ -93,16 +87,6 @@ export function AccessControlEntry() { if (groupsQuery.isPending || (appId && bindingQuery.isPending)) return null const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) - const workflowState = appInfo ? getPublishedWorkflowState(appInfo, workflow) : undefined - const triggerAvailable = getAccessControlScopeAvailability({ - mode: appInfo?.mode, - hasTriggerNode: Boolean(workflowState?.hasTriggerNode), - isUnpublished: Boolean(workflowState?.isUnpublished), - }).trigger - const availability = availabilityFromAccessPoints( - bindingQuery.data?.available_access_points ?? [], - triggerAvailable, - ) const groups = groupsQuery.data?.groups ?? [] const binding = bindingQuery.data?.binding const policies = groups.map((group) => ({ @@ -110,25 +94,29 @@ export function AccessControlEntry() { name: group.name, allowed_cidrs: group.allowed_cidrs, })) - const baseline = draftFromBinding(binding, availability) + const baseline = draftFromBinding(binding) const resolvedDraft = draft ?? baseline const assignment: AccessControlAssignment | null = binding?.group_id ? { policyId: binding.group_id, policyName: groups.find((group) => group.id === binding.group_id)?.name ?? binding.group_id, - scopes: scopesFromAccessPoints(binding.access_points, availability), + scopes: scopesFromAccessPoints(binding.access_points), enabled: binding.enabled, } : null const entitled = bindingQuery.data?.entitled ?? groupsQuery.data?.entitled const canMutate = entitled === true const dirty = !isAccessControlDraftEqual(resolvedDraft, baseline) + const persistableAccessPoints = accessPointsFromScopes( + resolvedDraft.scopes, + bindingQuery.data?.available_access_points, + ) const canSave = canSaveAccessControl({ draft: resolvedDraft, - availability, baseline, + persistableAccessPoints, }) - const chip = getAccessControlChipState({ entitled, assignment, availability }) + const chip = getAccessControlChipState({ entitled, assignment }) const showPaywall = chip.kind === 'pro' const showStatus = Boolean(assignment) && (view === 'status' || !canMutate) @@ -176,10 +164,21 @@ export function AccessControlEntry() { } const handleCreatePolicy = () => { + setOpen(false) + setCreatePolicyOpen(true) + } + + const handleManagePolicies = () => { setOpen(false) void setSettingsDestination('ip-policies') } + const handlePolicyDialogOpenChange = (nextOpen: boolean) => { + if (nextOpen) return + setCreatePolicyOpen(false) + setOpen(true) + } + const persistBinding = ( { enabled, @@ -228,7 +227,7 @@ export function AccessControlEntry() { { enabled: resolvedDraft.enabled, groupId: resolvedDraft.selectedPolicyId, - accessPoints: accessPointsFromScopes(resolvedDraft.scopes, availability), + accessPoints: persistableAccessPoints, }, () => { setDraft(null) @@ -244,79 +243,121 @@ export function AccessControlEntry() { } return ( - - { - setOpen(nextOpen) - if (!nextOpen) { - setDraft(null) + <> + + { + setOpen(nextOpen) + if (!nextOpen) { + setDraft(null) + setShowBack(false) + return + } + setView(assignment || !canMutate ? 'status' : 'config') setShowBack(false) - return - } - setView(assignment || !canMutate ? 'status' : 'config') - setShowBack(false) - }} - > - - } - > - - {label} - - - - {showPaywall ? ( - - ) : showStatus && assignment ? ( - { - if (!canMutate) return - setDraft(resolvedDraft) - setShowBack(true) - setView('config') - }} - /> - ) : ( - + } + > + + {label} + - )} - - - {tooltip} - + + + {showPaywall ? ( + + ) : showStatus && assignment ? ( + { + if (!canMutate) return + setDraft(resolvedDraft) + setShowBack(true) + setView('config') + }} + /> + ) : ( + + )} + + + {tooltip} + + {createPolicyOpen && ( + { + createGroup.mutate( + { + body: { + name: payload.name, + description: '', + allowed_cidrs: payload.allowed_cidrs, + }, + }, + { + onSuccess: (data) => { + queryClient.setQueryData( + consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions().queryKey, + (current) => { + if (!current) return current + if (current.groups.some((group) => group.id === data.group.id)) return current + return { + ...current, + groups: [...current.groups, data.group], + } + }, + ) + setDraft({ + ...resolvedDraft, + selectedPolicyId: data.group.id, + }) + setCreatePolicyOpen(false) + setView('config') + setOpen(true) + }, + }, + ) + }} + /> + )} + ) } diff --git a/web/app/components/app/access-point/access-control/network-access.ts b/web/app/components/app/access-point/access-control/network-access.ts index 7331cbdb779037..ceb7d6f9f091d8 100644 --- a/web/app/components/app/access-point/access-control/network-access.ts +++ b/web/app/components/app/access-point/access-control/network-access.ts @@ -2,7 +2,7 @@ import type { AppNetworkAccessGroupBindingResponse, AppNetworkAccessGroupResponse, } from '@dify/contracts/api/console/apps/types.gen' -import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessControlDraft } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { createDefaultAccessControlDraft } from './draft' @@ -31,26 +31,6 @@ export function toUiAccessPoint(accessPoint: NetworkAccessPoint): AccessPoint { return API_TO_ACCESS_POINT[accessPoint] } -export function availabilityFromAccessPoints( - availableAccessPoints: readonly string[], - triggerAvailable: boolean, -): AccessControlScopeAvailability { - const allowed = new Set( - availableAccessPoints.flatMap((accessPoint) => { - if (accessPoint in API_TO_ACCESS_POINT) - return [toUiAccessPoint(accessPoint as NetworkAccessPoint)] - return [] - }), - ) - - return { - webApp: allowed.has('webApp'), - serviceApi: allowed.has('serviceApi'), - mcp: allowed.has('mcp'), - trigger: allowed.has('trigger') && triggerAvailable, - } -} - export function getNetworkAccessErrorStatus(error: unknown): number | undefined { if (error instanceof Response) return error.status if (typeof error === 'object' && error !== null) { @@ -68,7 +48,6 @@ export function getNetworkAccessErrorStatus(error: unknown): number | undefined export function scopesFromAccessPoints( accessPoints: readonly string[], - availability: AccessControlScopeAvailability, ): AccessControlDraft['scopes'] { const selected = new Set( accessPoints.flatMap((accessPoint) => { @@ -79,31 +58,34 @@ export function scopesFromAccessPoints( ) return { - webApp: availability.webApp && selected.has('webApp'), - serviceApi: availability.serviceApi && selected.has('serviceApi'), - mcp: availability.mcp && selected.has('mcp'), - trigger: availability.trigger && selected.has('trigger'), + webApp: selected.has('webApp'), + serviceApi: selected.has('serviceApi'), + mcp: selected.has('mcp'), + trigger: selected.has('trigger'), } } export function accessPointsFromScopes( scopes: AccessControlDraft['scopes'], - availability: AccessControlScopeAvailability, + availableAccessPoints?: readonly string[], ): NetworkAccessPoint[] { - return ACCESS_POINT_ORDER.filter( - (accessPoint) => availability[accessPoint] && scopes[accessPoint], - ).map(toApiAccessPoint) + const allowed = availableAccessPoints ? new Set(availableAccessPoints) : null + + return ACCESS_POINT_ORDER.filter((accessPoint) => { + if (!scopes[accessPoint]) return false + if (!allowed) return true + return allowed.has(toApiAccessPoint(accessPoint)) + }).map(toApiAccessPoint) } export function draftFromBinding( binding: AppNetworkAccessGroupBindingResponse | null | undefined, - availability: AccessControlScopeAvailability, ): AccessControlDraft { - if (!binding?.group_id) return createDefaultAccessControlDraft(availability) + if (!binding?.group_id) return createDefaultAccessControlDraft() return { selectedPolicyId: binding.group_id, enabled: binding.enabled, - scopes: scopesFromAccessPoints(binding.access_points, availability), + scopes: scopesFromAccessPoints(binding.access_points), } } diff --git a/web/app/components/app/access-point/access-control/scope-list.tsx b/web/app/components/app/access-point/access-control/scope-list.tsx index d3039743aeed96..044c24f9030189 100644 --- a/web/app/components/app/access-point/access-control/scope-list.tsx +++ b/web/app/components/app/access-point/access-control/scope-list.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AccessControlDraft, AccessControlScopeAvailability } from './draft' +import type { AccessControlDraft } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' @@ -17,17 +17,10 @@ const SCOPE_ICONS: Record, string> = { type AccessControlScopeListProps = { draft: AccessControlDraft - disabled: boolean - availability: AccessControlScopeAvailability onDraftChange: (draft: AccessControlDraft) => void } -export function AccessControlScopeList({ - draft, - disabled, - availability, - onDraftChange, -}: AccessControlScopeListProps) { +export function AccessControlScopeList({ draft, onDraftChange }: AccessControlScopeListProps) { const { t } = useTranslation() const appInfo = useAppStore((state) => state.appDetail) @@ -41,7 +34,6 @@ export function AccessControlScopeList({ return (
{ACCESS_POINT_ORDER.map((scope) => { - const unavailable = !availability[scope] const label = labels[scope] return ( @@ -57,34 +49,16 @@ export function AccessControlScopeList({ className="rounded-sm bg-util-colors-orange-orange-100" /> ) : ( - + )} - - {label} - - {unavailable && ( - - {t(($) => $['studio.accessControl.notEnabled'], { ns: 'deployments' })} - - )} + {label} { onDraftChange({ diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx index c439fa1ea6e7e2..79eefa2f1759fd 100644 --- a/web/app/components/app/access-point/access-control/status-panel.tsx +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -1,10 +1,6 @@ 'use client' -import type { - AccessControlDraft, - AccessControlPolicy, - AccessControlScopeAvailability, -} from './draft' +import type { AccessControlDraft, AccessControlPolicy } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { AlertDialog, @@ -26,7 +22,6 @@ import { useStore as useAppStore } from '@/app/components/app/store' import AppIcon from '@/app/components/base/app-icon' import { splitPolicySummary } from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' import { getInServiceCoverage } from './chip-status' -import { isProtectableAccessPoint } from './draft' const SCOPE_ICONS: Record, string> = { serviceApi: 'i-custom-vender-knowledge-api-aggregate', @@ -38,7 +33,6 @@ type AccessControlStatusPanelProps = { draft: AccessControlDraft policies: readonly AccessControlPolicy[] enabled: boolean - availability: AccessControlScopeAvailability onEdit: () => void updating?: boolean dirty?: boolean @@ -54,7 +48,6 @@ export function AccessControlStatusPanel({ draft, policies, enabled, - availability, onEdit, updating = false, dirty = false, @@ -70,16 +63,16 @@ export function AccessControlStatusPanel({ const [confirmPause, setConfirmPause] = useState(false) const selectedPolicy = policies.find((policy) => policy.id === draft.selectedPolicyId) const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.allowed_cidrs) : undefined - const coverage = getInServiceCoverage(draft.scopes, availability) + const coverage = getInServiceCoverage(draft.scopes) const labels: Record = { webApp: t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }), serviceApi: t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }), mcp: t(($) => $['mcp.server.title'], { ns: 'tools' }), trigger: t(($) => $['settings.trigger'], { ns: 'common' }), } - const exposedNames = ACCESS_POINT_ORDER.filter( - (scope) => availability[scope] && draft.scopes[scope], - ).map((scope) => labels[scope]) + const exposedNames = ACCESS_POINT_ORDER.filter((scope) => draft.scopes[scope]).map( + (scope) => labels[scope], + ) const policyName = selectedPolicy?.name return ( @@ -171,8 +164,7 @@ export function AccessControlStatusPanel({ {t(($) => $['studio.accessControl.applyTo'], { ns: 'deployments' })}

{ACCESS_POINT_ORDER.map((scope) => { - const unavailable = !isProtectableAccessPoint(scope, availability) - const excluded = !unavailable && !draft.scopes[scope] + const excluded = !draft.scopes[scope] const label = labels[scope] return ( @@ -191,7 +183,7 @@ export function AccessControlStatusPanel({ {label} @@ -215,11 +205,6 @@ export function AccessControlStatusPanel({ {t(($) => $['studio.accessControl.excluded'], { ns: 'deployments' })} )} - {unavailable && ( - - {t(($) => $['studio.accessControl.notEnabled'], { ns: 'deployments' })} - - )}
) })} From 96ea949d3e6e73603ba92f81da317413b51b11b5 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 11 Sep 2026 19:32:46 +0800 Subject: [PATCH 029/108] fix(web): drop come-back copy from IP policy empty states Create now opens the New IP Policy dialog in place, so the empty-state prompt to create a policy and come back no longer applies. Keep the title until PRD finalizes replacement copy. --- .../access-control/__tests__/index.spec.tsx | 6 ++---- .../app/access-point/access-control/policy-field.tsx | 11 +++-------- .../ip-policies-page/__tests__/index.spec.tsx | 2 -- .../header/account-setting/ip-policies-page/index.tsx | 5 +---- web/i18n/ar-TN/deployments.json | 1 - web/i18n/de-DE/deployments.json | 1 - web/i18n/en-US/deployments.json | 1 - web/i18n/es-ES/deployments.json | 1 - web/i18n/fa-IR/deployments.json | 1 - web/i18n/fr-FR/deployments.json | 1 - web/i18n/hi-IN/deployments.json | 1 - web/i18n/id-ID/deployments.json | 1 - web/i18n/it-IT/deployments.json | 1 - web/i18n/ja-JP/deployments.json | 1 - web/i18n/ko-KR/deployments.json | 1 - web/i18n/lo-LA/deployments.json | 1 - web/i18n/nl-NL/deployments.json | 1 - web/i18n/pl-PL/deployments.json | 1 - web/i18n/pt-BR/deployments.json | 1 - web/i18n/ro-RO/deployments.json | 1 - web/i18n/ru-RU/deployments.json | 1 - web/i18n/sl-SI/deployments.json | 1 - web/i18n/th-TH/deployments.json | 1 - web/i18n/tr-TR/deployments.json | 1 - web/i18n/uk-UA/deployments.json | 1 - web/i18n/vi-VN/deployments.json | 1 - web/i18n/zh-Hans/deployments.json | 1 - web/i18n/zh-Hant/deployments.json | 1 - 28 files changed, 6 insertions(+), 42 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index cee1e530a801b0..84bb45a8f1daa2 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -38,8 +38,6 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.applyToHelp': 'Choose which access points to protect.', 'studio.accessControl.applyToHelpSelected': 'Choose which access points this policy protects.', 'studio.accessControl.createIpPolicy': 'Create an IP policy', - 'studio.accessControl.emptyPoliciesDescription': - 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', 'studio.accessControl.chipOff': 'Off', 'studio.accessControl.chipOn': 'ON', @@ -235,10 +233,10 @@ describe('AccessControlEntry', () => { expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() expect( - screen.getByText( + screen.queryByText( 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', ), - ).toBeInTheDocument() + ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx index f1dfe8feb29497..e423382fe803b0 100644 --- a/web/app/components/app/access-point/access-control/policy-field.tsx +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -53,14 +53,9 @@ export function AccessControlPolicyField({ if (policies.length === 0) { return (
-
-

- {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} -

-

- {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} -

-
+

+ {t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })} +

diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index a57e5418f94d2f..cc20e736d53d99 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -41,8 +41,6 @@ const translations = vi.hoisted(() => ({ 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', 'settings.ipPolicyNewTitle': 'New IP Policy', 'settings.ipPolicyRemoveEntry': 'Remove entry', - 'studio.accessControl.emptyPoliciesDescription': - 'A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.', 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', 'studio.accessControl.policySummaryTwo': 'Allows {{first}} and {{second}}', })) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index 072ad56b5c60ab..939d10cb7735e3 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -109,13 +109,10 @@ export default function IpPoliciesPage() { )} {!isPending && !isError && groups.length === 0 && ( -
+

{t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })}

-

- {t(($) => $['studio.accessControl.emptyPoliciesDescription'], { ns: 'deployments' })} -

)} diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index 47a0077a600473..50edb101fdf980 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index e8aaa306986ab6..7f3003ed5dd6e2 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index 40c2e37a7d3c61..725869b4f215ee 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index 1b491ba695fa92..3246ab04d5c681 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index 3da73b07904998..bd35fd9d722064 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index 876e0692c737c8..e58b7f0fb15b15 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index e65c5cc49ca364..3686632ede5436 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index bc2150db7414b1..191d134875348f 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index 06617769cf28cc..b645ac4c180ba2 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index 8caa284e94c549..07c2a94bf6bf95 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "IP ポリシーを作成", - "studio.accessControl.emptyPoliciesDescription": "ポリシーは許可する IP アドレスの一覧です。作成してから、ここに戻って適用してください。", "studio.accessControl.emptyPoliciesTitle": "このワークスペースにはまだ IP ポリシーがありません", "studio.accessControl.entryLabel": "アクセス制御", "studio.accessControl.excluded": "対象外", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index 565a340e7a5833..b9e5bca46c9ad8 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index 14ff57cefa7a0b..16a358398c0c46 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index 352fb5e69d87a1..84ebae52841b75 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index 2d1e134e80a678..909d9c54606c57 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index 2d80b823e9b166..2cac09e728fb86 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index 41ee3dbd032c60..070a4707b4e752 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index edb87e9bd767b4..a2c027fc2d9ecd 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 8c73bb434a07ec..111b1a91f1e707 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index 78313160ac624a..0f904b9aba25ce 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index 3704c873ee6d6f..d669c9f8f7a627 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index eac5c4b1ed883a..bff0384f39fc7c 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index 3080f70f9996d3..beb093511394bb 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", "studio.accessControl.createIpPolicy": "Create an IP policy", - "studio.accessControl.emptyPoliciesDescription": "A policy is the list of IP addresses allowed in. Create one, then come back to apply it here.", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index 7b8a36b31b546e..8fb78539e289a0 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "创建 IP 策略", - "studio.accessControl.emptyPoliciesDescription": "策略是允许访问的 IP 地址列表。先创建一条,再回到这里应用。", "studio.accessControl.emptyPoliciesTitle": "当前工作空间还没有 IP 策略", "studio.accessControl.entryLabel": "访问控制", "studio.accessControl.excluded": "未覆盖", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index f865016900c01a..54fa828cbf4cd4 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -41,7 +41,6 @@ "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", "studio.accessControl.createIpPolicy": "建立 IP 策略", - "studio.accessControl.emptyPoliciesDescription": "策略是允許存取的 IP 位址清單。先建立一條,再回到這裡套用。", "studio.accessControl.emptyPoliciesTitle": "目前工作區還沒有 IP 策略", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "未涵蓋", From 5ef488e3b068234ce5b3769abf8014c189cf6029 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 11 Sep 2026 20:07:21 +0800 Subject: [PATCH 030/108] feat(web): restore Access Control drafts and add downgrade resume paywall Keep dirty drafts across Esc/outside close and reopen the edit layer. After a plan downgrade, show the Figma resume popover for a saved assignment. --- .../access-control/__tests__/index.spec.tsx | 236 +++++++++++++++++- .../access-control/downgrade-panel.tsx | 118 +++++++++ .../app/access-point/access-control/index.tsx | 25 +- web/i18n/ar-TN/deployments.json | 4 + web/i18n/de-DE/deployments.json | 4 + web/i18n/en-US/deployments.json | 4 + web/i18n/es-ES/deployments.json | 4 + web/i18n/fa-IR/deployments.json | 4 + web/i18n/fr-FR/deployments.json | 4 + web/i18n/hi-IN/deployments.json | 4 + web/i18n/id-ID/deployments.json | 4 + web/i18n/it-IT/deployments.json | 4 + web/i18n/ja-JP/deployments.json | 4 + web/i18n/ko-KR/deployments.json | 4 + web/i18n/lo-LA/deployments.json | 4 + web/i18n/nl-NL/deployments.json | 4 + web/i18n/pl-PL/deployments.json | 4 + web/i18n/pt-BR/deployments.json | 4 + web/i18n/ro-RO/deployments.json | 4 + web/i18n/ru-RU/deployments.json | 4 + web/i18n/sl-SI/deployments.json | 4 + web/i18n/th-TH/deployments.json | 4 + web/i18n/tr-TR/deployments.json | 4 + web/i18n/uk-UA/deployments.json | 4 + web/i18n/vi-VN/deployments.json | 4 + web/i18n/zh-Hans/deployments.json | 4 + web/i18n/zh-Hant/deployments.json | 4 + 27 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 web/app/components/app/access-point/access-control/downgrade-panel.tsx diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 84bb45a8f1daa2..8b1436c1d8d240 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -14,6 +14,7 @@ import { AccessControlEntry } from '..' const mockSetPricing = vi.fn() const mockSetSettingsDestination = vi.fn() const accessControlTranslations = vi.hoisted(() => ({ + 'operation.back': 'Back', 'operation.cancel': 'Cancel', 'operation.close': 'Close', 'operation.save': 'Save', @@ -38,6 +39,8 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.applyToHelp': 'Choose which access points to protect.', 'studio.accessControl.applyToHelpSelected': 'Choose which access points this policy protects.', 'studio.accessControl.createIpPolicy': 'Create an IP policy', + 'studio.accessControl.downgradeDescription': 'Access control needs the Pro plan.', + 'studio.accessControl.downgradeTitle': 'This app is no longer protected', 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', 'studio.accessControl.chipOff': 'Off', 'studio.accessControl.chipOn': 'ON', @@ -53,6 +56,8 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.previewCaption': "This app is only available on your organization's network.", 'studio.accessControl.proBadge': 'PRO', + 'studio.accessControl.protectsCoverage': 'Protects {{n}} of {{m}} access points', + 'studio.accessControl.readyToResume': 'Ready to resume', 'studio.accessControl.selectPolicy': 'Select a policy', 'studio.accessControl.tooltipOff': 'Not set up', 'studio.accessControl.tooltipPro': 'Access control requires the Pro plan', @@ -143,7 +148,7 @@ describe('AccessControlEntry', () => { expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() }) - it('shows a saved assignment on Cloud sandbox instead of hiding it behind the paywall', async () => { + it('shows a resume paywall for a saved assignment on Cloud sandbox', async () => { const user = userEvent.setup() renderEntry({ plan: 'sandbox', @@ -163,9 +168,17 @@ describe('AccessControlEntry', () => { expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() await user.click(getChip()) - expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Turn on Access Control' })).toBeInTheDocument() + expect(screen.getByText('This app is no longer protected')).toBeInTheDocument() + expect(screen.getByText('Access control needs the Pro plan.')).toBeInTheDocument() + expect(screen.getByText('Ready to resume')).toBeInTheDocument() + expect(screen.getByText('Internal Network')).toBeInTheDocument() + expect(screen.getByText('Protects 3 of 4 access points')).toBeInTheDocument() + expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('switch', { name: 'Restrict by IP address' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Turn on Access Control' })) + expect(mockSetPricing).toHaveBeenCalledWith('open') }) it('renders a single chip with a non-interactive PRO badge for Cloud sandbox', () => { @@ -404,4 +417,221 @@ describe('AccessControlEntry', () => { expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() }) + + it('discards an unsaved first-time draft on Cancel', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Trigger' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + }) + + await user.click(getChip()) + expect(screen.getByRole('switch', { name: 'Trigger' })).toBeChecked() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('restores an unsaved first-time draft after Escape', async () => { + const user = userEvent.setup() + renderEntry({ plan: 'professional' }) + + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Trigger' })) + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + }) + + await user.click(getChip()) + expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('restores an unsaved edit after Escape and keeps the saved chip', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Edit' })) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + + await user.click(getChip()) + expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() + expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + }) + + it('discards an unsaved edit on Cancel', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Edit' })) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() + }) + + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + it('discards an unsaved edit on Back', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Edit' })) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + await user.click(screen.getByRole('button', { name: 'Back' })) + + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() + }) + + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + }) + + it('does not restore a clean edit session after Escape', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() + }) + + it('reopens the edit layer after dismissing a paused draft with Escape', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + }) + + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + await user.click(screen.getByRole('button', { name: 'Turn off' })) + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + + await user.keyboard('{Escape}') + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + + await user.click(getChip()) + expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + }) }) diff --git a/web/app/components/app/access-point/access-control/downgrade-panel.tsx b/web/app/components/app/access-point/access-control/downgrade-panel.tsx new file mode 100644 index 00000000000000..b948f97666061b --- /dev/null +++ b/web/app/components/app/access-point/access-control/downgrade-panel.tsx @@ -0,0 +1,118 @@ +'use client' + +import type { AccessControlAssignment } from './chip-status' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { PopoverDescription, PopoverTitle } from '@langgenius/dify-ui/popover' +import { useTranslation } from 'react-i18next' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' +import { useStore as useAppStore } from '@/app/components/app/store' +import AppIcon from '@/app/components/base/app-icon' +import { getInServiceCoverage } from './chip-status' + +const SCOPE_ICONS: Record, string> = { + serviceApi: 'i-custom-vender-knowledge-api-aggregate', + mcp: 'i-custom-vender-integrations-mcp', + trigger: 'i-custom-vender-integrations-trigger', +} + +type AccessControlDowngradePanelProps = { + assignment: AccessControlAssignment + onTurnOn: () => void +} + +export function AccessControlDowngradePanel({ + assignment, + onTurnOn, +}: AccessControlDowngradePanelProps) { + const { t } = useTranslation() + const appInfo = useAppStore((state) => state.appDetail) + const coverage = getInServiceCoverage(assignment.scopes) + const protectedScopes = ACCESS_POINT_ORDER.filter((scope) => assignment.scopes[scope]) + const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) + const turnOn = t(($) => $['studio.accessControl.turnOn'], { ns: 'deployments' }) + const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) + + return ( +
+
+ + {title} + +
+
+
+

+ {t(($) => $['studio.accessControl.downgradeTitle'], { ns: 'deployments' })} +

+ + {t(($) => $['studio.accessControl.downgradeDescription'], { ns: 'deployments' })} + +
+
+
+

+ {t(($) => $['studio.accessControl.readyToResume'], { ns: 'deployments' })} +

+

{assignment.policyName}

+
+
+ {protectedScopes.map((scope) => + scope === 'webApp' ? ( + + ) : ( + + + + ), + )} +
+

+ {t(($) => $['studio.accessControl.protectsCoverage'], { + ns: 'deployments', + n: coverage.coveredCount, + m: coverage.inServiceCount, + })} +

+
+
+
+ + + + + {pro} + + + +
+
+
+
+ ) +} diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index b15b2a2fb6843a..a4ed26725baab5 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -25,6 +25,7 @@ import { consoleQuery } from '@/service/console' import { AccessControlChipAffix } from './chip-affix' import { getAccessControlChipState } from './chip-status' import { AccessControlConfigPanel } from './config-panel' +import { AccessControlDowngradePanel } from './downgrade-panel' import { canSaveAccessControl, isAccessControlDraftEqual } from './draft' import { AccessControlFreePaywall } from './free-paywall' import { @@ -118,7 +119,8 @@ export function AccessControlEntry() { }) const chip = getAccessControlChipState({ entitled, assignment }) const showPaywall = chip.kind === 'pro' - const showStatus = Boolean(assignment) && (view === 'status' || !canMutate) + const showDowngrade = Boolean(assignment) && !canMutate + const showStatus = Boolean(assignment) && canMutate && view === 'status' const tooltip = chip.kind === 'pro' @@ -148,9 +150,13 @@ export function AccessControlEntry() { if (gtag) gtag('event', 'click_upgrade_btn', { loc: 'access-control-paywall' }) } - const handleCancel = () => { + const discardDraft = () => { setDraft(null) setShowBack(false) + } + + const handleCancel = () => { + discardDraft() if (assignment) { setView('status') return @@ -159,7 +165,7 @@ export function AccessControlEntry() { } const handleBack = () => { - setShowBack(false) + discardDraft() setView('status') } @@ -230,9 +236,8 @@ export function AccessControlEntry() { accessPoints: persistableAccessPoints, }, () => { - setDraft(null) + discardDraft() setView('status') - setShowBack(false) }, ) } @@ -250,8 +255,12 @@ export function AccessControlEntry() { onOpenChange={(nextOpen) => { setOpen(nextOpen) if (!nextOpen) { - setDraft(null) - setShowBack(false) + if (!dirty) discardDraft() + return + } + if (dirty) { + setView('config') + setShowBack(Boolean(assignment) && canMutate) return } setView(assignment || !canMutate ? 'status' : 'config') @@ -277,6 +286,8 @@ export function AccessControlEntry() { > {showPaywall ? ( + ) : showDowngrade && assignment ? ( + ) : showStatus && assignment ? ( Date: Thu, 17 Sep 2026 10:29:47 +0800 Subject: [PATCH 031/108] feat(web): show IP access restrictions across public app requests --- .../components/__tests__/splash.spec.tsx | 192 +++++++++- web/app/(shareLayout)/components/splash.tsx | 101 ++++-- .../base/chat/chat/__tests__/hooks.spec.tsx | 287 +++++++++++++++ web/app/components/base/chat/chat/hooks.ts | 61 +++- .../file-uploader/__tests__/hooks.spec.ts | 104 ++++-- .../components/base/file-uploader/hooks.ts | 11 +- .../image-uploader/__tests__/hooks.spec.ts | 87 +++-- .../components/base/image-uploader/hooks.ts | 6 +- .../__tests__/use-result-run-state.spec.ts | 132 +++++++ .../hooks/__tests__/use-result-sender.spec.ts | 74 ++++ .../result/hooks/use-result-run-state.ts | 38 +- .../result/hooks/use-result-sender.ts | 20 ++ web/app/layout.tsx | 3 +- .../__tests__/access-restricted.spec.tsx | 84 +++++ .../__tests__/boundary.spec.tsx | 195 ++++++++++ .../webapp-ip-access/__tests__/state.spec.ts | 97 +++++ .../webapp-ip-access/access-restricted.tsx | 69 ++++ web/features/webapp-ip-access/boundary.tsx | 36 ++ web/features/webapp-ip-access/locale-menu.tsx | 53 +++ web/features/webapp-ip-access/state.ts | 116 ++++++ web/i18n/ar-TN/share.json | 3 + web/i18n/de-DE/share.json | 3 + web/i18n/en-US/share.json | 3 + web/i18n/es-ES/share.json | 3 + web/i18n/fa-IR/share.json | 3 + web/i18n/fr-FR/share.json | 3 + web/i18n/hi-IN/share.json | 3 + web/i18n/id-ID/share.json | 3 + web/i18n/it-IT/share.json | 3 + web/i18n/ja-JP/share.json | 3 + web/i18n/ko-KR/share.json | 3 + web/i18n/lo-LA/share.json | 3 + web/i18n/nl-NL/share.json | 3 + web/i18n/pl-PL/share.json | 3 + web/i18n/pt-BR/share.json | 3 + web/i18n/ro-RO/share.json | 3 + web/i18n/ru-RU/share.json | 3 + web/i18n/sl-SI/share.json | 3 + web/i18n/th-TH/share.json | 3 + web/i18n/tr-TR/share.json | 3 + web/i18n/uk-UA/share.json | 3 + web/i18n/vi-VN/share.json | 3 + web/i18n/zh-Hans/share.json | 3 + web/i18n/zh-Hant/share.json | 3 + web/public/illustrations/ip-access-denied.png | Bin 0 -> 10590 bytes web/service/__tests__/base-request.spec.ts | 3 +- .../__tests__/ip-access-denied.spec.ts | 339 ++++++++++++++++++ .../share-human-input-upload.spec.ts | 108 ++++++ web/service/base.ts | 66 +++- web/service/fetch.ts | 24 +- web/service/share.ts | 22 +- web/utils/is-abort-error.ts | 5 + 52 files changed, 2268 insertions(+), 137 deletions(-) create mode 100644 web/features/webapp-ip-access/__tests__/access-restricted.spec.tsx create mode 100644 web/features/webapp-ip-access/__tests__/boundary.spec.tsx create mode 100644 web/features/webapp-ip-access/__tests__/state.spec.ts create mode 100644 web/features/webapp-ip-access/access-restricted.tsx create mode 100644 web/features/webapp-ip-access/boundary.tsx create mode 100644 web/features/webapp-ip-access/locale-menu.tsx create mode 100644 web/features/webapp-ip-access/state.ts create mode 100644 web/public/illustrations/ip-access-denied.png create mode 100644 web/service/__tests__/ip-access-denied.spec.ts create mode 100644 web/utils/is-abort-error.ts diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index c9b5582b030fce..d4693d436febbc 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import Splash from '../splash' const navigationMocks = vi.hoisted(() => ({ @@ -15,6 +15,14 @@ const webAppAuthMocks = vi.hoisted(() => ({ })) const fetchAccessTokenMock = vi.hoisted(() => vi.fn()) +const ipAccessState = vi.hoisted(() => ({ + scope: {}, + isCurrent: true, + isDenied: false, + deniedError: new Error('IP access denied'), +})) + +const routerMock = { replace: navigationMocks.replace } const webAppState: { shareCode: string | null @@ -32,10 +40,17 @@ vi.mock('@/context/web-app-context', () => ({ vi.mock('@/next/navigation', () => ({ usePathname: () => navigationMocks.pathname, - useRouter: () => ({ replace: navigationMocks.replace }), + useRouter: () => routerMock, useSearchParams: () => navigationMocks.searchParams, })) +vi.mock('@/features/webapp-ip-access/state', () => ({ + captureIpAccessScope: () => ipAccessState.scope, + hasIpAccessDenied: () => ipAccessState.isDenied, + isIpAccessScopeCurrent: () => ipAccessState.isCurrent, + isIpAccessDeniedError: (error: unknown) => error === ipAccessState.deniedError, +})) + vi.mock('@/service/share', () => ({ fetchAccessToken: (...args: unknown[]) => fetchAccessTokenMock(...args), })) @@ -45,6 +60,10 @@ vi.mock('@/service/webapp-auth', () => webAppAuthMocks) describe('Splash', () => { beforeEach(() => { vi.clearAllMocks() + fetchAccessTokenMock.mockReset() + webAppAuthMocks.webAppLogout.mockResolvedValue(undefined) + ipAccessState.isCurrent = true + ipAccessState.isDenied = false webAppState.shareCode = 'share-app' webAppState.webAppAccessMode = 'public' webAppState.embeddedUserId = 'embedded-user' @@ -224,4 +243,173 @@ describe('Splash', () => { expect(screen.getByRole('button', { name: 'share.login.backToHome' })).toBeInTheDocument() }) + + it.each(['login status', 'passport'])( + 'should stop initialization when %s is denied by IP policy', + async (request) => { + navigationMocks.searchParams = new URLSearchParams() + if (request === 'login status') { + webAppAuthMocks.webAppLoginStatus.mockRejectedValue(ipAccessState.deniedError) + } else { + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue(ipAccessState.deniedError) + } + + await act(async () => { + render( + +
share application
+
, + ) + }) + + expect(webAppAuthMocks.webAppLoginStatus).toHaveBeenCalledTimes(1) + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + expect(screen.queryByText('share.common.appUnavailable')).not.toBeInTheDocument() + }, + ) + + it('should show an unavailable state when login status fails without allowing the app to load', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockRejectedValue(new Response(null, { status: 503 })) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnknownError')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '503' })).toBeInTheDocument() + expect(fetchAccessTokenMock).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it('should keep authentication and block initialization when the passport policy service is unavailable', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue( + Response.json({ code: 'policy_unavailable' }, { status: 503 }), + ) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnknownError')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '503' })).toBeInTheDocument() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it.each(['IP denial', 'navigation', 'unmount'])( + 'should ignore login status received after %s', + async (interruption) => { + navigationMocks.searchParams = new URLSearchParams() + let finishLogin!: (value: { userLoggedIn: boolean; appLoggedIn: boolean }) => void + webAppAuthMocks.webAppLoginStatus.mockReturnValue( + new Promise((resolve) => { + finishLogin = resolve + }), + ) + const { unmount } = render( + +
share application
+
, + ) + + if (interruption === 'IP denial') ipAccessState.isDenied = true + else if (interruption === 'navigation') ipAccessState.isCurrent = false + else unmount() + + await act(async () => { + finishLogin({ userLoggedIn: true, appLoggedIn: false }) + }) + + expect(fetchAccessTokenMock).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }, + ) + + it.each(['IP denial', 'navigation', 'unmount'])( + 'should ignore a passport received after %s', + async (interruption) => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + let finishPassport!: (value: { access_token: string }) => void + fetchAccessTokenMock.mockReturnValue( + new Promise((resolve) => { + finishPassport = resolve + }), + ) + const { unmount } = render( + +
share application
+
, + ) + await waitFor(() => expect(fetchAccessTokenMock).toHaveBeenCalledTimes(1)) + + if (interruption === 'IP denial') ipAccessState.isDenied = true + else if (interruption === 'navigation') ipAccessState.isCurrent = false + else unmount() + + await act(async () => { + finishPassport({ access_token: 'late-passport' }) + }) + + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }, + ) + + it('should not sign out after a pending passport fails following IP denial', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + let rejectPassport!: (error: Response) => void + fetchAccessTokenMock.mockReturnValue( + new Promise((_resolve, reject) => { + rejectPassport = reject + }), + ) + render( + +
share application
+
, + ) + await waitFor(() => expect(fetchAccessTokenMock).toHaveBeenCalledTimes(1)) + + ipAccessState.isDenied = true + await act(async () => { + rejectPassport(new Response(null, { status: 401 })) + }) + + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) }) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index eaede783da1d64..668117c6ec3183 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -10,6 +10,12 @@ import { import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessDeniedError, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' @@ -60,8 +66,17 @@ function Splash({ children }: PropsWithChildren) { }, [getSigninUrl, pathname, redirectUrl, router]) const [isLoading, setIsLoading] = useState(true) - const [unavailableShareCode, setUnavailableShareCode] = useState() + const [initializationError, setInitializationError] = useState<{ + shareCode: string + status?: number + }>() useEffect(() => { + const scope = captureIpAccessScope() + let cancelled = false + const canContinue = () => + !cancelled && (scope === null || isIpAccessScopeCurrent(scope)) && !hasIpAccessDenied(scope) + if (!canContinue()) return + const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) const isSigninRoute = isWebAppSigninPath(pathname) if ((redirectUrl !== null && !loginRedirect) || (isSigninRoute && !loginRedirect)) { @@ -78,11 +93,13 @@ function Splash({ children }: PropsWithChildren) { if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl) const redirectOrFinish = () => { + if (!canContinue()) return if (loginRedirect) navigateAfterWebAppLogin(loginRedirect, router.replace, basePath) else setIsLoading(false) } const proceedToAuth = (authenticationRequired: boolean) => { + if (!canContinue()) return if ( authenticationRequired && address.kind === 'environment' && @@ -100,37 +117,59 @@ function Splash({ children }: PropsWithChildren) { setIsLoading(false) } - ;(async () => { - // if access mode is public, user login is always true, but the app login(passport) may be expired - const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( - effectiveShareCode, - embeddedUserId || undefined, - ) - if (userLoggedIn && appLoggedIn) { - redirectOrFinish() - } else if (!userLoggedIn && !appLoggedIn) { - proceedToAuth(true) - } else if (!userLoggedIn && appLoggedIn) { - redirectOrFinish() - } else if (userLoggedIn && !appLoggedIn) { - try { - const { access_token } = await fetchAccessToken({ - appCode: effectiveShareCode, - userId: embeddedUserId || undefined, - }) - setWebAppPassport(address, access_token) + const initialize = async () => { + try { + // Public apps still need a valid app passport even when user login is not required. + const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( + effectiveShareCode, + embeddedUserId || undefined, + ) + if (!canContinue()) return + + if (appLoggedIn) { redirectOrFinish() - } catch (error) { - if (error instanceof Response && error.status === 404) { - setUnavailableShareCode(effectiveShareCode) + } else if (!userLoggedIn) { + proceedToAuth(true) + } else { + try { + const { access_token } = await fetchAccessToken({ + appCode: effectiveShareCode, + userId: embeddedUserId || undefined, + }) + if (!canContinue()) return + + setWebAppPassport(address, access_token) + redirectOrFinish() + } catch (error) { + if (!canContinue() || isIpAccessDeniedError(error)) return + + if (error instanceof Response && error.status >= 500) { + setInitializationError({ shareCode: effectiveShareCode, status: error.status }) + return + } + if (error instanceof Response && error.status === 404) { + setInitializationError({ shareCode: effectiveShareCode, status: 404 }) + await webAppLogout(address) + return + } await webAppLogout(address) - return + proceedToAuth(error instanceof Response && error.status === 401) } - await webAppLogout(address) - proceedToAuth(error instanceof Response && error.status === 401) } + } catch (error) { + if (!canContinue() || isIpAccessDeniedError(error)) return + + setInitializationError({ + shareCode: effectiveShareCode, + status: error instanceof Response ? error.status : undefined, + }) } - })() + } + + void initialize() + return () => { + cancelled = true + } }, [ shareCode, redirectUrl, @@ -140,6 +179,7 @@ function Splash({ children }: PropsWithChildren) { webAppAccessMode, tokenFromUrl, embeddedUserId, + searchParams, ]) if (message) { @@ -163,10 +203,13 @@ function Splash({ children }: PropsWithChildren) { ) } - if (unavailableShareCode === shareCode) { + if (initializationError?.shareCode === shareCode) { return (
- + $['common.appUnavailable'], { ns: 'share' })} + isUnknownReason={initializationError.status !== 404} + />
) } diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index acaabee193553d..b85aa932527556 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -1,7 +1,9 @@ import type { ChatConfig, ChatItemInTree } from '../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { toast } from '@langgenius/dify-ui/toast' import { act, renderHook } from '@testing-library/react' import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' import { useChat } from '../hooks' @@ -49,6 +51,15 @@ const createAbortControllerMock = () => { vi.spyOn(controller, 'abort') return controller } +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} type HookCallbacks = { getAbortController: (abortController: AbortController) => void onCompleted: (hasError?: boolean, errorMessage?: string) => Promise | void @@ -81,6 +92,8 @@ type UseChatFormSettings = NonNullable[1]> describe('useChat', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureIpAccessScope() vi.useFakeTimers() vi.mocked(useParams).mockReturnValue({} as ReturnType) vi.mocked(usePathname).mockReturnValue('') @@ -905,6 +918,215 @@ describe('useChat', () => { expect(lastResponse!.agent_thoughts![0]!.message_files).toHaveLength(1) }) + it('should retain the streamed answer and original settlement when history fails', async () => { + const historyError = new Response(null, { status: 503 }) + const onGetConversationMessages = vi.fn().mockRejectedValue(historyError) + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result } = renderHook(() => + useChat({ + suggested_questions_after_answer: { enabled: true }, + } as ChatConfig), + ) + + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + await act(async () => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + await expect(callbacks.onCompleted()).resolves.toBeUndefined() + }) + + expect(result.current.chatList.at(-1)?.content).toBe('Streamed answer') + expect(result.current.isResponding).toBe(false) + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).toHaveBeenCalledExactlyOnceWith(undefined) + expect(toast.error).not.toHaveBeenCalled() + }) + + it.each(['denied', 'navigation', 'new request', 'stop', 'unmount'] as const)( + 'should ignore history results after %s', + async (transition) => { + window.history.replaceState({}, '', '/agent/history-test') + const scope = captureIpAccessScope() + const history = createDeferred() + const onGetConversationMessages = vi.fn(() => history.promise) + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result, unmount } = renderHook(() => + useChat( + { suggested_questions_after_answer: { enabled: true } } as ChatConfig, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { isNewAgent: true }, + ), + ) + + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + expect(onGetConversationMessages).toHaveBeenCalledOnce() + + act(() => { + if (transition === 'navigation') window.history.replaceState({}, '', '/agent/another-app') + if (transition === 'new request') + result.current.handleSend( + 'chat-messages', + { query: 'next question' }, + { isPublicAPI: true }, + ) + if (transition === 'stop') result.current.handleStop() + if (transition === 'unmount') unmount() + if (transition === 'denied') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, scope) + }) + const chatListBeforeHistory = result.current.chatList + await act(async () => { + history.resolve({ data: [{ id: 'answer-1', answer: 'Late history answer' }] }) + await completion + }) + + expect(result.current.chatList).toEqual(chatListBeforeHistory) + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each(['denied', 'navigation', 'unmount'] as const)( + 'should consume history rejection after %s without completion callbacks', + async (transition) => { + window.history.replaceState({}, '', '/agent/history-rejection') + const scope = captureIpAccessScope() + const history = createDeferred() + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result, unmount } = renderHook(() => useChat()) + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetConversationMessages: () => history.promise, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + + await act(async () => { + const error = new Response(null, { status: transition === 'denied' ? 403 : 503 }) + if (transition === 'denied') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, scope, error) + if (transition === 'navigation') window.history.replaceState({}, '', '/chat/another-app') + if (transition === 'unmount') unmount() + history.reject(error) + await expect(completion).resolves.toBeUndefined() + }) + + expect(result.current.chatList.at(-1)?.content).toBe('Streamed answer') + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each(['resolve', 'reject'] as const)( + 'should ignore a suggested question %s after navigation', + async (outcome) => { + window.history.replaceState({}, '', '/agent/suggestions-test') + const suggestions = createDeferred() + const onSendSettled = vi.fn() + const { result } = renderHook(() => + useChat({ + suggested_questions_after_answer: { enabled: true }, + } as ChatConfig), + ) + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetSuggestedQuestions: () => suggestions.promise, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + await act(async () => { + window.history.replaceState({}, '', '/agent/another-app') + if (outcome === 'resolve') suggestions.resolve({ data: ['Late suggestion'] }) + else suggestions.reject(new Error('Late failure')) + await completion + }) + + expect(result.current.suggestedQuestions).toEqual([]) + expect(onSendSettled).not.toHaveBeenCalled() + }, + ) + it('should fetch conversation messages and suggested questions onCompleted', async () => { let callbacks: HookCallbacks @@ -2193,6 +2415,71 @@ describe('useChat', () => { }) describe('handleStop and handleRestart', () => { + it.each(['rejection', 'throw'] as const)( + 'should finish stopping locally after a service %s', + async (failure) => { + const error = new Error('Stop request failed') + const stopChat = vi.fn(() => { + if (failure === 'throw') throw error + return Promise.reject(error) + }) + const { result, unmount } = renderHook(() => + useChat(undefined, undefined, undefined, stopChat), + ) + act(() => { + result.current.handleSend('chat-messages', { query: 'test' }, {}) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + const controller = createAbortControllerMock() + await act(async () => { + callbacks.onWorkflowStarted({ task_id: 'task-123' }) + callbacks.getAbortController(controller) + result.current.handleStop() + }) + + expect(result.current.isResponding).toBe(false) + expect(controller.abort).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + unmount() + }, + ) + + it.each([false, true])( + 'should consume a rejected stop request when unmounted is %s', + async (shouldUnmount) => { + window.history.replaceState({}, '', '/agent/stop-test') + const scope = captureIpAccessScope() + const error = new Response(null, { status: 403 }) + const stopRequest = createDeferred() + const stopChat = vi.fn(() => stopRequest.promise) + const { result, unmount } = renderHook(() => + useChat(undefined, undefined, undefined, stopChat), + ) + + act(() => { + result.current.handleSend('chat-messages', { query: 'test' }, { isPublicAPI: true }) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + const controller = createAbortControllerMock() + act(() => { + callbacks.onWorkflowStarted({ task_id: 'task-123' }) + callbacks.getAbortController(controller) + result.current.handleStop() + }) + + expect(stopChat).toHaveBeenCalledWith('task-123') + expect(result.current.isResponding).toBe(false) + expect(controller.abort).toHaveBeenCalledOnce() + + await act(async () => { + if (shouldUnmount) unmount() + handleIpAccessDenied(403, { code: 'ip_access_denied' }, scope, error) + stopRequest.reject(error) + }) + expect(toast.error).not.toHaveBeenCalled() + }, + ) + it('should set responded false and call stopChat and abort controllers', () => { const stopChat = vi.fn() const { result } = renderHook(() => useChat(undefined, undefined, undefined, stopChat)) diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index c35af2fb373102..cedf781eb701ec 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -22,6 +22,11 @@ import { import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes' import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import useTimestamp from '@/hooks/use-timestamp' import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' @@ -192,7 +197,7 @@ export const useChat = ( inputsForm: InputForm[] }, prevChatTree?: ChatItemInTree[], - stopChat?: (taskId: string) => void, + stopChat?: (taskId: string) => void | Promise, clearChatList?: boolean, clearChatListCallback?: (state: boolean) => void, initialConversationId?: string, @@ -527,7 +532,15 @@ export const useChat = ( const handleStop = useCallback(() => { hasStopRespondedRef.current = true handleResponding(false) - if (stopChat && taskIdRef.current && !pausedStateRef.current) stopChat(taskIdRef.current) + if (stopChat && taskIdRef.current && !pausedStateRef.current) { + void (async () => { + try { + await stopChat(taskIdRef.current) + } catch { + // The request layer owns error feedback; stopping locally must still finish. + } + })() + } if (conversationMessagesAbortControllerRef.current) conversationMessagesAbortControllerRef.current.abort() if (suggestedQuestionsAbortControllerRef.current) @@ -1109,6 +1122,11 @@ export const useChat = ( pausedStateRef.current = false resetWorkflowEventsSubscription() const requestGeneration = ++workflowRequestGenerationRef.current + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null + const isCurrentRequest = () => + requestGeneration === workflowRequestGenerationRef.current && + isIpAccessScopeCurrent(ipAccessScope) && + !hasIpAccessDenied(ipAccessScope) const parentMessage = threadMessages.find((item) => item.id === data.parent_message_id) @@ -1181,13 +1199,13 @@ export const useChat = ( let hasNotifiedConversationComplete = false let currentWorkflowRunId = '' const settleSend = (hasError?: boolean) => { - if (hasSettled) return + if (hasSettled || !isCurrentRequest()) return hasSettled = true onSendSettled?.(hasError) } const notifyConversationComplete = (workflowRunId?: string) => { - if (hasNotifiedConversationComplete) return + if (hasNotifiedConversationComplete || !isCurrentRequest()) return hasNotifiedConversationComplete = true onConversationComplete?.(conversationIdRef.current, workflowRunId) @@ -1254,7 +1272,7 @@ export const useChat = ( }) }, async onCompleted(hasError?: boolean) { - if (requestGeneration !== workflowRequestGenerationRef.current) return + if (!isCurrentRequest()) return handleResponding(false) @@ -1268,11 +1286,22 @@ export const useChat = ( !hasStopRespondedRef.current && onGetConversationMessages ) { - const conversationMessagesResponse = await onGetConversationMessages( - conversationIdRef.current, - (newAbortController) => - (conversationMessagesAbortControllerRef.current = newAbortController), - ) + let conversationMessagesResponse: unknown + try { + conversationMessagesResponse = await onGetConversationMessages( + conversationIdRef.current, + (newAbortController) => { + if (isCurrentRequest()) + conversationMessagesAbortControllerRef.current = newAbortController + else newAbortController.abort() + }, + ) + } catch { + // Keep the streamed answer and its settlement when history cannot be refreshed. + return + } + if (!isCurrentRequest()) return + const data = getConversationMessagesData(conversationMessagesResponse) const newResponseItem = data.find((item) => item.id === responseItem.id) completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId @@ -1335,6 +1364,7 @@ export const useChat = ( notifyConversationComplete(completedWorkflowRunId) if ( + isCurrentRequest() && config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions @@ -1342,12 +1372,15 @@ export const useChat = ( try { const { data }: any = await onGetSuggestedQuestions( responseItem.id, - (newAbortController) => - (suggestedQuestionsAbortControllerRef.current = newAbortController), + (newAbortController) => { + if (isCurrentRequest()) + suggestedQuestionsAbortControllerRef.current = newAbortController + else newAbortController.abort() + }, ) - setSuggestedQuestions(data) + if (isCurrentRequest()) setSuggestedQuestions(data) } catch { - setSuggestedQuestions([]) + if (isCurrentRequest()) setSuggestedQuestions([]) } } } finally { diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 3ba3ce253957ac..9a14a4ccd204bf 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -2,9 +2,20 @@ import type { FileEntity } from '../types' import type { FileUpload } from '@/app/components/base/features/types' import type { FileUploadConfigResponse } from '@/models/common' import { act, renderHook } from '@testing-library/react' +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' import { useFile, useFileSizeLimit } from '../hooks' const mockNotify = vi.fn() + +const createUploadError = (ipDenied: boolean | 'cancelled') => { + if (ipDenied === 'cancelled') return new DOMException('Upload cancelled', 'AbortError') + const error = new Error('Upload failed') + if (ipDenied) { + window.history.replaceState({}, '', '/chat/restricted-app') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope(), error) + } + return error +} const mockNavigationState = vi.hoisted(() => ({ params: {} as { token?: string }, pathname: '/chat', @@ -316,28 +327,33 @@ describe('useFile', () => { expect(mockSetFiles).toHaveBeenCalled() }) - it('should handle error callback during re-upload', () => { - const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - mockStoreFiles = [ - { - id: 'file-1', - name: 'test.txt', - type: 'text/plain', - size: 100, - progress: -1, - transferMethod: 'local_file', - supportFileType: 'document', - originalFile, - }, - ] as FileEntity[] - - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleReUploadFile('file-1') - - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onErrorCallback(new Error('fail')) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) + it.each([false, true, 'cancelled'] as const)( + 'finishes failed re-upload and suppresses IP denial or cancellation (%s)', + (ipDenied) => { + const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' }) + mockStoreFiles = [ + { + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 100, + progress: -1, + transferMethod: 'local_file', + supportFileType: 'document', + originalFile, + }, + ] as FileEntity[] + + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleReUploadFile('file-1') + + const uploadCall = mockFileUpload.mock.calls[0]![0] + uploadCall.onErrorCallback(createUploadError(ipDenied)) + expect(mockSetFiles).toHaveBeenCalled() + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }, + ) }) describe('handleLoadFileFromLink', () => { @@ -464,17 +480,22 @@ describe('useFile', () => { expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], []) }) - it('should remove file when remote upload fails', async () => { - mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error')) + it.each([false, true, 'cancelled'] as const)( + 'removes failed remote uploads and suppresses IP denial or cancellation (%s)', + async (ipDenied) => { + mockUploadRemoteFileInfo.mockRejectedValue(createUploadError(ipDenied)) - const { result } = renderHook(() => useFile(defaultFileConfig)) - await act(async () => { - result.current.handleLoadFileFromLink('https://example.com/file.txt') - await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled()) - }) + const { result } = renderHook(() => useFile(defaultFileConfig)) + await act(async () => { + result.current.handleLoadFileFromLink('https://example.com/file.txt') + await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled()) + }) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) + expect(mockSetFiles).toHaveBeenCalled() + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }, + ) it('should remove file when size limit is exceeded on remote upload', async () => { mockGetSupportFileType.mockReturnValue('image') @@ -843,17 +864,22 @@ describe('useFile', () => { expect(mockFileUpload).not.toHaveBeenCalled() }) - it('should handle fileUpload error callback', () => { - const file = new File(['content'], 'test.txt', { type: 'text/plain' }) + it.each([false, true, 'cancelled'] as const)( + 'finishes failed local upload and suppresses IP denial or cancellation (%s)', + (ipDenied) => { + const file = new File(['content'], 'test.txt', { type: 'text/plain' }) - const { result } = renderHook(() => useFile(defaultFileConfig)) - result.current.handleLocalFileUpload(file) + const { result } = renderHook(() => useFile(defaultFileConfig)) + result.current.handleLocalFileUpload(file) - const uploadCall = mockFileUpload.mock.calls[0]![0] - uploadCall.onErrorCallback(new Error('upload failed')) + const uploadCall = mockFileUpload.mock.calls[0]![0] + uploadCall.onErrorCallback(createUploadError(ipDenied)) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - }) + expect(mockSetFiles).toHaveBeenCalled() + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }, + ) it('should handle FileReader error event', () => { capturedListeners = {} diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 1e62e59ab3b72b..0cb59c2c28886e 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -17,11 +17,13 @@ import { VIDEO_SIZE_LIMIT, } from '@/app/components/base/file-uploader/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { isIpAccessDeniedError } from '@/features/webapp-ip-access/state' import { useParams, usePathname } from '@/next/navigation' import { uploadRemoteFileInfo } from '@/service/common' import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' +import { isAbortError } from '@/utils/is-abort-error' import { useFileStore } from './store' import { useFileUploadContext } from './upload-context' import { @@ -191,7 +193,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isIpAccessDeniedError(error) && !isAbortError(error)) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, } @@ -278,8 +280,9 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { handleRemoveFile(uploadingFile.id) else handleUpdateFile(newFile) }) - .catch(() => { - toast.error(t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) + .catch((error: unknown) => { + if (!isIpAccessDeniedError(error) && !isAbortError(error)) + toast.error(t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) handleRemoveFile(uploadingFile.id) }) }, @@ -372,7 +375,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isIpAccessDeniedError(error) && !isAbortError(error)) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, } diff --git a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts index 586ffc9335762d..7087f1d86939d6 100644 --- a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts @@ -1,9 +1,20 @@ import type { ImageFile } from '@/types/app' import { act, renderHook } from '@testing-library/react' +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' import { TransferMethod } from '@/types/app' import { useImageFiles, useLocalFileUploader } from '../hooks' const mockNotify = vi.fn() + +const createUploadError = (ipDenied: boolean | 'cancelled') => { + if (ipDenied === 'cancelled') return new DOMException('Upload cancelled', 'AbortError') + const error = new Error('Upload failed') + if (ipDenied) { + window.history.replaceState({}, '', '/chat/restricted-app') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope(), error) + } + return error +} vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (message: string) => mockNotify({ type: 'error', message }), @@ -263,28 +274,32 @@ describe('useImageFiles', () => { expect(result.current.files[0]!.progress).toBe(100) }) - it('should set progress to -1 and notify on error callback during re-upload', () => { - const { result } = renderHook(() => useImageFiles()) - const file = new File(['test'], 'test.png', { type: 'image/png' }) - const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + it.each([false, true, 'cancelled'] as const)( + 'sets failed re-upload progress and suppresses IP denial or cancellation (%s)', + (ipDenied) => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) - act(() => { - result.current.onUpload(imageFile) - }) + act(() => { + result.current.onUpload(imageFile) + }) - act(() => { - result.current.onReUpload('file-1') - }) + act(() => { + result.current.onReUpload('file-1') + }) - const uploadCall = mockImageUpload.mock.calls[0]![0] + const uploadCall = mockImageUpload.mock.calls[0]![0] - act(() => { - uploadCall.onErrorCallback(new Error('Network error')) - }) + act(() => { + uploadCall.onErrorCallback(createUploadError(ipDenied)) + }) - expect(result.current.files[0]!.progress).toBe(-1) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Upload error' }) - }) + expect(result.current.files[0]!.progress).toBe(-1) + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Upload error' }) + }, + ) }) it('should filter out deleted files in returned files', () => { @@ -437,27 +452,31 @@ describe('useLocalFileUploader', () => { ) }) - it('should notify error and call onUpload with progress -1 on upload failure', async () => { - const onUpload = vi.fn() - const { result } = renderHook(() => useLocalFileUploader({ onUpload })) + it.each([false, true, 'cancelled'] as const)( + 'sets failed local upload progress and suppresses IP denial or cancellation (%s)', + async (ipDenied) => { + const onUpload = vi.fn() + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) - const file = new File(['test'], 'test.png', { type: 'image/png' }) + const file = new File(['test'], 'test.png', { type: 'image/png' }) - act(() => { - result.current.handleLocalFileUpload(file) - }) + act(() => { + result.current.handleLocalFileUpload(file) + }) - await vi.waitFor(() => { - expect(mockImageUpload).toHaveBeenCalled() - }) + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) - const uploadCall = mockImageUpload.mock.calls[0]![0] + const uploadCall = mockImageUpload.mock.calls[0]![0] - act(() => { - uploadCall.onErrorCallback(new Error('fail')) - }) + act(() => { + uploadCall.onErrorCallback(createUploadError(ipDenied)) + }) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) - }) + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) + }, + ) }) diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index 0f363f8b1dd4d4..41692a3e698d98 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -2,8 +2,10 @@ import type { ImageFile } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { isIpAccessDeniedError } from '@/features/webapp-ip-access/state' import { useParams } from '@/next/navigation' import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' +import { isAbortError } from '@/utils/is-abort-error' import { getImageUploadErrorMessage, imageUpload } from './utils' export const useImageFiles = () => { @@ -103,7 +105,7 @@ export const useImageFiles = () => { t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isIpAccessDeniedError(error) && !isAbortError(error)) toast.error(errorMessage) const newFiles = [ ...files.slice(0, index), { ...currentImageFile, progress: -1 }, @@ -188,7 +190,7 @@ export const useLocalFileUploader = ({ t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isIpAccessDeniedError(error) && !isAbortError(error)) toast.error(errorMessage) onUpload({ ...imageFile, progress: -1 }) }, }, diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts index c48fcaf09ef00b..952d3dc7dba1a8 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts @@ -1,5 +1,6 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type' import { act, renderHook, waitFor } from '@testing-library/react' +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' import { AppSourceType } from '@/service/share' import { useResultRunState } from '../use-result-run-state' @@ -27,6 +28,8 @@ vi.mock('@/service/share', async () => { describe('useResultRunState', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureIpAccessScope() stopChatMessageRespondingMock.mockResolvedValue(undefined) stopWorkflowMessageMock.mockResolvedValue(undefined) updateFeedbackMock.mockResolvedValue(undefined) @@ -75,6 +78,135 @@ describe('useResultRunState', () => { expect(abort).toHaveBeenCalledTimes(1) }) + it.each([false, true])( + 'should suppress IP denial feedback when stopping a workflow is %s', + async (isWorkflow) => { + window.history.replaceState( + {}, + '', + isWorkflow ? '/workflow/stop-test' : '/completion/stop-test', + ) + const scope = captureIpAccessScope() + const error = new Response(null, { status: 403 }) + const stop = isWorkflow ? stopWorkflowMessageMock : stopChatMessageRespondingMock + stop.mockImplementationOnce(async () => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, scope, error) + throw error + }) + const notify = vi.fn() + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow, + notify, + }), + ) + act(() => result.current.setCurrentTaskId('task-denied')) + + await act(async () => { + await expect(result.current.handleStop()).resolves.toBeUndefined() + }) + + expect(stop).toHaveBeenCalledOnce() + expect(notify).not.toHaveBeenCalled() + }, + ) + + it.each([ + ['new run', 'resolve'], + ['new run', 'reject'], + ['navigation', 'resolve'], + ['navigation', 'reject'], + ['unmount', 'resolve'], + ['unmount', 'reject'], + ] as const)('should ignore a stop after %s when it ends with %s', async (transition, outcome) => { + window.history.replaceState({}, '', '/completion/stop-race') + let resolveStop!: () => void + let rejectStop!: (error: unknown) => void + const stopRequest = new Promise((resolve, reject) => { + resolveStop = resolve + rejectStop = reject + }) + stopChatMessageRespondingMock.mockReturnValueOnce(stopRequest) + const notify = vi.fn() + const onRunControlChange = vi.fn() + const { result, unmount } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow: false, + notify, + onRunControlChange, + }), + ) + const controller = new AbortController() + const newController = new AbortController() + const abort = vi.spyOn(controller, 'abort') + const abortNewRun = vi.spyOn(newController, 'abort') + act(() => { + result.current.abortControllerRef.current = controller + result.current.setCurrentTaskId('task-old') + result.current.setRespondingTrue() + }) + let stopping: Promise + act(() => { + stopping = result.current.handleStop() + }) + expect(result.current.isStopping).toBe(true) + + act(() => { + if (transition === 'navigation') + window.history.replaceState({}, '', '/completion/another-app') + if (transition === 'unmount') unmount() + if (transition === 'new run') { + result.current.prepareForNewRun() + result.current.abortControllerRef.current = newController + result.current.setCurrentTaskId('task-new') + result.current.setIsStopping(true) + } + }) + const controlsBeforeCompletion = onRunControlChange.mock.calls.length + const abortsBeforeCompletion = abort.mock.calls.length + + await act(async () => { + if (outcome === 'resolve') resolveStop() + else rejectStop(new Error('Late stop failure')) + await stopping + }) + + expect(notify).not.toHaveBeenCalled() + expect(abortNewRun).not.toHaveBeenCalled() + expect(abort).toHaveBeenCalledTimes(abortsBeforeCompletion) + expect(onRunControlChange).toHaveBeenCalledTimes(controlsBeforeCompletion) + if (transition === 'new run') { + expect(result.current.currentTaskId).toBe('task-new') + expect(result.current.isStopping).toBe(true) + } + }) + + it('should not send a stop request after another public request denies access', async () => { + window.history.replaceState({}, '', '/workflow/already-denied') + const scope = captureIpAccessScope() + const notify = vi.fn() + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow: true, + notify, + }), + ) + act(() => { + result.current.setCurrentTaskId('task-denied') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, scope) + }) + await act(async () => { + await result.current.handleStop() + }) + + expect(stopWorkflowMessageMock).not.toHaveBeenCalled() + expect(notify).not.toHaveBeenCalled() + expect(result.current.isStopping).toBe(false) + }) + it('should update feedback and react to external stop control', async () => { const notify = vi.fn() const onRunControlChange = vi.fn() diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts index 8dc1dba662be18..93e76dde507695 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts @@ -4,6 +4,7 @@ import type { PromptConfig } from '@/models/debug' import type { AppSourceType } from '@/service/share' import type { VisionSettings } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' import { AppSourceType as AppSourceTypeEnum } from '@/service/share' import { withSelectorKey } from '@/test/i18n-mock' import { Resolution, TransferMethod } from '@/types/app' @@ -243,6 +244,8 @@ const renderSender = ({ describe('useResultSender', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureIpAccessScope() mockWebAppState.appInfo.mode = 'completion' validateResultRequestMock.mockReturnValue({ canSend: true }) buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } }) @@ -555,6 +558,77 @@ describe('useResultSender', () => { expect(onCompleted).toHaveBeenCalledTimes(1) }) + it.each([ + { transition: 'denied', timedOut: true, callback: 'onError' as const }, + { transition: 'navigated', timedOut: true, callback: 'onError' as const }, + { transition: 'denied', timedOut: false, callback: 'onError' as const }, + { transition: 'navigated', timedOut: false, callback: 'onError' as const }, + { transition: 'denied', timedOut: true, callback: 'onCompleted' as const }, + { transition: 'navigated', timedOut: true, callback: 'onCompleted' as const }, + { transition: 'denied', timedOut: false, callback: 'onCompleted' as const }, + ])( + 'settles $callback silently after $transition with timeout $timedOut', + async ({ transition, timedOut, callback }) => { + window.history.replaceState({}, '', '/completion/restricted-app') + const harness = createRunStateHarness() + let completionHandlers: CompletionHandlers | undefined + if (timedOut) sleepMock.mockResolvedValue(undefined) + sendCompletionMessageMock.mockImplementation(async (_data, handlers) => { + completionHandlers = handlers as CompletionHandlers + }) + const { result, notify, onCompleted } = renderSender({ + runState: harness.runState, + taskId: 14, + }) + + await act(async () => { + await result.current.handleSend() + }) + + await act(async () => { + if (transition === 'denied') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope()) + else { + window.history.replaceState({}, '', '/completion/another-app') + captureIpAccessScope() + } + completionHandlers![callback]() + }) + + expect(notify).not.toHaveBeenCalled() + expect(harness.runState.setRespondingFalse).toHaveBeenCalled() + expect(harness.runState.resetRunState).toHaveBeenCalled() + expect(onCompleted).toHaveBeenCalledExactlyOnceWith('', 14, false) + expect(harness.runState.setMessageId).not.toHaveBeenCalled() + }, + ) + + it('silently settles a rejected workflow request after its page was denied', async () => { + window.history.replaceState({}, '', '/workflow/restricted-app') + let rejectRequest!: (error: Error) => void + const pending = new Promise((_resolve, reject) => { + rejectRequest = reject + }) + sendWorkflowMessageMock.mockReturnValue(pending) + const harness = createRunStateHarness() + const { result, notify, onCompleted } = renderSender({ + runState: harness.runState, + isWorkflow: true, + taskId: 15, + }) + + await act(async () => { + await result.current.handleSend() + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope()) + rejectRequest(new Error('Late workflow failure')) + }) + + expect(notify).not.toHaveBeenCalled() + expect(harness.runState.setRespondingFalse).toHaveBeenCalled() + expect(harness.runState.resetRunState).toHaveBeenCalled() + expect(onCompleted).toHaveBeenCalledExactlyOnceWith('', 15, false) + }) + it('should handle non-timeout stream errors as failed completions', async () => { const harness = createRunStateHarness() let completionHandlers: CompletionHandlers | undefined diff --git a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts index 8c3fd273b51f58..25da6c7b2ae930 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts @@ -1,10 +1,20 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' -import type { AppSourceType } from '@/service/share' import { useBoolean } from 'ahooks' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessDeniedError, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' +import { + AppSourceType, + stopChatMessageResponding, + stopWorkflowMessage, + updateFeedback, +} from '@/service/share' type Notify = (payload: { type: 'error'; message: string }) => void @@ -97,6 +107,7 @@ export const useResultRunState = ({ }) const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0) const abortControllerRef = useRef(null) + const runGenerationRef = useRef(0) const [{ currentTaskId, isStopping }, dispatchRunControl] = useReducer(runControlReducer, { currentTaskId: null, isStopping: false, @@ -131,6 +142,7 @@ export const useResultRunState = ({ const getWorkflowProcessData = useCallback(() => workflowProcessDataRef.current, []) const resetRunState = useCallback(() => { + runGenerationRef.current += 1 dispatchRunControl({ type: 'reset' }) abortControllerRef.current = null onRunControlChange?.(null) @@ -165,17 +177,28 @@ export const useResultRunState = ({ const handleStop = useCallback(async () => { if (!currentTaskId || isStopping) return + const runGeneration = runGenerationRef.current + const ipAccessScope = appSourceType === AppSourceType.webApp ? captureIpAccessScope() : null + const isCurrentRequest = () => + runGeneration === runGenerationRef.current && + isIpAccessScopeCurrent(ipAccessScope) && + !hasIpAccessDenied(ipAccessScope) + if (!isCurrentRequest()) return + + const abortController = abortControllerRef.current setIsStopping(true) try { if (isWorkflow) await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') else await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') - abortControllerRef.current?.abort() + if (isCurrentRequest()) abortController?.abort() } catch (error) { + if (!isCurrentRequest() || isIpAccessDeniedError(error)) return + const message = error instanceof Error ? error.message : String(error) notify({ type: 'error', message }) } finally { - setIsStopping(false) + if (isCurrentRequest()) setIsStopping(false) } }, [appId, appSourceType, currentTaskId, isStopping, isWorkflow, notify, setIsStopping]) @@ -183,6 +206,13 @@ export const useResultRunState = ({ setControlClearMoreLikeThis(Date.now()) }, []) + useEffect( + () => () => { + runGenerationRef.current += 1 + }, + [], + ) + useEffect(() => { const abortCurrentRequest = () => { abortControllerRef.current?.abort() diff --git a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts index f9d94e7afd6ddd..0984a9648e641d 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts @@ -7,6 +7,11 @@ import { useCallback, useEffect, useRef } from 'react' import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event' import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' import { useWebAppStore } from '@/context/web-app-context' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share' import { sleep } from '@/utils' import { buildResultRequestData, validateResultRequest } from '../result-request' @@ -89,6 +94,7 @@ export const useResultSender = ({ promptConfig, visionConfig, }) + const ipAccessScope = appSourceType === AppSourceType.webApp ? captureIpAccessScope() : null runState.prepareForNewRun() @@ -106,6 +112,17 @@ export const useResultSender = ({ let isTimeout = false let completionChunks: string[] = [] let tempMessageId = '' + const stopInactiveRequest = () => { + if (isIpAccessScopeCurrent(ipAccessScope) && !hasIpAccessDenied(ipAccessScope)) return false + + if (!isEnd) { + runState.setRespondingFalse() + runState.resetRunState() + if (!isTimeout) onCompleted(runState.getCompletionRes(), taskId, false) + isEnd = true + } + return true + } void (async () => { await sleep(TEXT_GENERATION_TIMEOUT_MS) @@ -140,6 +157,7 @@ export const useResultSender = ({ }) void sendWorkflowMessage(data, otherOptions, appSourceType, appId).catch((error) => { + if (stopInactiveRequest()) return runState.setRespondingFalse() runState.resetRunState() logRequestError(notify, error) @@ -159,6 +177,7 @@ export const useResultSender = ({ runState.setCompletionRes(completionChunks.join('')) }, onCompleted: () => { + if (stopInactiveRequest()) return if (isTimeout) { notify({ type: 'warning', @@ -178,6 +197,7 @@ export const useResultSender = ({ runState.setCompletionRes(completionChunks.join('')) }, onError: () => { + if (stopInactiveRequest()) return if (isTimeout) { notify({ type: 'warning', diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 50f9e13370a505..58a403c6c76614 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -13,6 +13,7 @@ import { dehydrateSystemFeatures, getOptionalSystemFeatures, } from '@/features/system-features/server' +import IpAccessBoundary from '@/features/webapp-ip-access/boundary' import { getLocaleOnServer } from '@/i18n-config/server' import { headers } from '@/next/headers' import { getApplicationTitle } from '@/utils/document-title' @@ -85,7 +86,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo - {children} + {children} diff --git a/web/features/webapp-ip-access/__tests__/access-restricted.spec.tsx b/web/features/webapp-ip-access/__tests__/access-restricted.spec.tsx new file mode 100644 index 00000000000000..e4c7df195d382e --- /dev/null +++ b/web/features/webapp-ip-access/__tests__/access-restricted.spec.tsx @@ -0,0 +1,84 @@ +import type { i18n as I18n } from 'i18next' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { I18nextProvider, initReactI18next } from 'react-i18next' +import { setLocaleOnClient } from '@/i18n-config' +import enShare from '@/i18n/en-US/share.json' +import zhShare from '@/i18n/zh-Hans/share.json' +import AccessRestricted from '../access-restricted' + +vi.unmock('react-i18next') + +vi.mock('@/i18n-config', () => ({ + setLocaleOnClient: vi.fn(), +})) + +let i18n: I18n + +const renderPage = (clientIp?: string) => + render( + + + , + ) + +describe('IP access restricted page', () => { + beforeEach(async () => { + vi.clearAllMocks() + i18n = createInstance() + await i18n.use(initReactI18next).init({ + lng: 'en-US', + fallbackLng: 'en-US', + defaultNS: 'share', + keySeparator: false, + interpolation: { escapeValue: false }, + resources: { + 'en-US': { share: enShare }, + 'zh-Hans': { share: zhShare }, + }, + }) + vi.mocked(setLocaleOnClient).mockImplementation(async (locale) => { + await i18n.changeLanguage(locale) + }) + }) + + it.each([ + ['en-US', '203.0.113.8', 'Your current IP address 203.0.113.8 isn’t on the allowlist.'], + [ + 'en-US', + '2001:db8:1234:5678:90ab:cdef:1234:5678', + 'Your current IP address 2001:db8:1234:5678:90ab:cdef:1234:5678 isn’t on the allowlist.', + ], + ['zh-Hans', '203.0.113.8', '您当前的 IP 地址 203.0.113.8 不在允许列表中。'], + ])('renders the full localized IP sentence for %s and %s', async (locale, clientIp, sentence) => { + await i18n.changeLanguage(locale) + renderPage(clientIp) + + const ip = screen.getByText(clientIp, { selector: 'code' }) + expect(ip.closest('p')?.textContent).toBe(sentence) + }) + + it('shows the network restriction without an IP sentence when the server omits the IP', () => { + renderPage() + + expect(screen.getByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + expect(screen.getByText(enShare['ipAccessDenied.description'])).toBeInTheDocument() + expect(screen.queryByText(/Your current IP address/)).not.toBeInTheDocument() + }) + + it('changes the page language without a reload and keeps the current IP', async () => { + const user = userEvent.setup() + renderPage('203.0.113.8') + + await user.click(screen.getByRole('button', { name: 'English' })) + await user.click(screen.getByRole('menuitemradio', { name: '简体中文' })) + + expect(setLocaleOnClient).toHaveBeenCalledWith('zh-Hans', false) + expect(await screen.findByRole('heading', { name: '访问受限' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '简体中文' })).toBeInTheDocument() + expect(screen.getByText('203.0.113.8', { selector: 'code' }).closest('p')?.textContent).toBe( + '您当前的 IP 地址 203.0.113.8 不在允许列表中。', + ) + }) +}) diff --git a/web/features/webapp-ip-access/__tests__/boundary.spec.tsx b/web/features/webapp-ip-access/__tests__/boundary.spec.tsx new file mode 100644 index 00000000000000..874a350c7fd00a --- /dev/null +++ b/web/features/webapp-ip-access/__tests__/boundary.spec.tsx @@ -0,0 +1,195 @@ +import { QueryClient, QueryClientProvider, queryOptions, useQuery } from '@tanstack/react-query' +import { act, render, screen } from '@testing-library/react' +import { createInstance } from 'i18next' +import { StrictMode } from 'react' +import { I18nextProvider, initReactI18next } from 'react-i18next' +import enShare from '@/i18n/en-US/share.json' +import IpAccessBoundary from '../boundary' +import { captureIpAccessScope, handleIpAccessDenied } from '../state' + +vi.unmock('react-i18next') +vi.mock('@/i18n-config', () => ({ setLocaleOnClient: vi.fn() })) +vi.mock('@/next/navigation', () => ({ + usePathname: () => window.location.pathname, + useSearchParams: () => new URLSearchParams(window.location.search), +})) + +const i18n = createInstance() + +function AppBootstrap({ request }: { request: () => Promise }) { + useQuery(queryOptions({ queryKey: ['app-bootstrap'], queryFn: request })) + return

Application loading

+} + +describe('IP denial page boundary', () => { + beforeEach(async () => { + window.history.replaceState({}, '', '/') + captureIpAccessScope() + await i18n.use(initReactI18next).init({ + lng: 'en-US', + fallbackLng: 'en-US', + defaultNS: 'share', + keySeparator: false, + interpolation: { escapeValue: false }, + resources: { 'en-US': { share: enShare } }, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each(['/chat/app', '/environment/workflow/app', '/form/token'])( + 'replaces %s and stops query retries after the first IP rejection', + async (path) => { + vi.useFakeTimers() + window.history.replaceState({}, '', path) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 3, retryDelay: 100 } }, + }) + const request = vi.fn(async () => { + const scope = captureIpAccessScope() + const error = new Response('', { status: 403 }) + handleIpAccessDenied( + 403, + { code: 'ip_access_denied', client_ip: '203.0.113.42' }, + scope, + error, + ) + throw error + }) + render( + + + + + + + , + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(request).toHaveBeenCalledTimes(1) + expect(screen.getByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + expect(screen.queryByText('Application loading')).not.toBeInTheDocument() + expect(screen.getByText('203.0.113.42', { selector: 'code' })).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(request).toHaveBeenCalledTimes(1) + queryClient.clear() + }, + ) + + it('renders the new app immediately and ignores the old app response after navigation', () => { + window.history.replaceState({}, '', '/chat/first') + const previousScope = captureIpAccessScope() + const contents = ( + + +

Current application

+
+
+ ) + const { rerender } = render(contents) + act(() => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + + window.history.replaceState({}, '', '/agent/second') + rerender( + + +

Current application

+
+
, + ) + act(() => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByText('Current application')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Access restricted' })).not.toBeInTheDocument() + }) + + it('starts a new visit after leaving for a console page with no public requests', () => { + window.history.replaceState({}, '', '/chat/app') + const previousScope = captureIpAccessScope() + const application = ( + + +

Current application

+
+
+ ) + const { rerender } = render(application) + act(() => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + + window.history.replaceState({}, '', '/apps') + rerender( + + +

Console

+
+
, + ) + expect(screen.getByText('Console')).toBeInTheDocument() + + window.history.replaceState({}, '', '/chat/app') + rerender(application) + const currentScope = captureIpAccessScope() + expect(currentScope?.key).toBe(previousScope?.key) + expect(currentScope?.epoch).not.toBe(previousScope?.epoch) + act(() => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByText('Current application')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Access restricted' })).not.toBeInTheDocument() + + act(() => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, currentScope) + }) + expect(screen.getByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + }) + + it('preserves a bootstrap rejection when Strict Mode replays effects', async () => { + window.history.replaceState({}, '', '/chat/app') + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const request = vi.fn(async () => { + const scope = captureIpAccessScope() + const error = new Response('', { status: 403 }) + handleIpAccessDenied( + 403, + { code: 'ip_access_denied', client_ip: '203.0.113.42' }, + scope, + error, + ) + throw error + }) + render( + + + + + + + + + , + ) + + expect(await screen.findByRole('heading', { name: 'Access restricted' })).toBeInTheDocument() + expect(screen.queryByText('Application loading')).not.toBeInTheDocument() + expect(screen.getByText('203.0.113.42', { selector: 'code' })).toBeInTheDocument() + expect(request).toHaveBeenCalledTimes(1) + queryClient.clear() + }) +}) diff --git a/web/features/webapp-ip-access/__tests__/state.spec.ts b/web/features/webapp-ip-access/__tests__/state.spec.ts new file mode 100644 index 00000000000000..f407225f7672ef --- /dev/null +++ b/web/features/webapp-ip-access/__tests__/state.spec.ts @@ -0,0 +1,97 @@ +import { + captureIpAccessScope, + getIpAccessScopeKey, + handleIpAccessDenied, + hasIpAccessDenied, + ipAccessDeniedAtom, + ipAccessStore, + isIpAccessDeniedError, + isIpAccessScopeCurrent, +} from '../state' + +const denied = { code: 'ip_access_denied', client_ip: '203.0.113.42' } +const navigate = (path: string) => { + window.history.replaceState({}, '', path) + return captureIpAccessScope() +} + +describe('WebApp IP denial scope', () => { + beforeEach(() => { + navigate('/') + }) + + it.each([ + ['/workflow/app', 'webapp:default:app'], + ['/chat/app', 'webapp:default:app'], + ['/chatbot/app', 'webapp:default:app'], + ['/completion/app', 'webapp:default:app'], + ['/agent/app', 'webapp:default:app'], + ['/environment/chat/app', 'webapp:environment:app'], + ['/environment/workflow/app', 'webapp:environment:app'], + ['/form/token', 'form:token'], + ])('scopes the public entry %s', (path, expected) => { + expect(navigate(path)?.key).toBe(expected) + }) + + it('resolves a validated sign-in target without scoping console or external redirects', () => { + expect(getIpAccessScopeKey('/webapp-signin', '?redirect_url=%2Fchat%2Fapp')).toBe( + 'webapp:default:app', + ) + expect(getIpAccessScopeKey('/apps', '?redirect_url=%2Fchat%2Fapp')).toBeNull() + expect( + getIpAccessScopeKey('/webapp-signin', '?redirect_url=https://untrusted.example/chat/app'), + ).toBeNull() + }) + + it('keeps one denial across concurrent requests and conversation query changes', () => { + const scope = navigate('/chat/app') + const error = new Response('', { status: 403 }) + expect(handleIpAccessDenied(403, denied, scope, error)).toBe(true) + const firstDenial = ipAccessStore.get(ipAccessDeniedAtom) + + const sameScope = navigate('/chat/app?conversation=second') + handleIpAccessDenied(403, denied, sameScope) + + expect(sameScope).toBe(scope) + expect(ipAccessStore.get(ipAccessDeniedAtom)).toBe(firstDenial) + expect(hasIpAccessDenied(scope)).toBe(true) + expect(isIpAccessDeniedError(error)).toBe(true) + }) + + it('ignores a late denial even after navigating back to the same app', () => { + const previousVisit = navigate('/chat/app') + navigate('/chat/other-app') + const currentVisit = navigate('/chat/app') + const error = new Response('', { status: 403 }) + + expect(handleIpAccessDenied(403, denied, previousVisit, error)).toBe(true) + expect(isIpAccessScopeCurrent(previousVisit)).toBe(false) + expect(hasIpAccessDenied(currentVisit)).toBe(false) + expect(isIpAccessDeniedError(error)).toBe(true) + }) + + it('does not carry a denial into another app or HITL form', () => { + handleIpAccessDenied(403, denied, navigate('/chat/app')) + const other = navigate('/form/token') + + expect(hasIpAccessDenied(other)).toBe(false) + expect(ipAccessStore.get(ipAccessDeniedAtom)).toBeNull() + }) + + it.each([ + [401, { code: 'unauthorized' }], + [403, { code: 'web_app_access_denied' }], + [503, { code: 'policy_unavailable' }], + ])('leaves %s %j to its existing error owner', (status, body) => { + const scope = navigate('/workflow/app') + expect(handleIpAccessDenied(status, body, scope)).toBe(false) + expect(hasIpAccessDenied(scope)).toBe(false) + }) + + it('does not suppress an unhandled console error with the same error code', () => { + const scope = navigate('/apps') + const error = { status: 403, response: denied } + expect(handleIpAccessDenied(403, denied, scope, error)).toBe(false) + expect(isIpAccessDeniedError(error)).toBe(false) + }) +}) diff --git a/web/features/webapp-ip-access/access-restricted.tsx b/web/features/webapp-ip-access/access-restricted.tsx new file mode 100644 index 00000000000000..706e85d5934d47 --- /dev/null +++ b/web/features/webapp-ip-access/access-restricted.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' +import { basePath } from '@/utils/var' +import LocaleMenu from './locale-menu' + +type AccessRestrictedProps = { + clientIp?: string +} + +export default function AccessRestricted({ clientIp }: AccessRestrictedProps) { + const { t } = useTranslation('share') + const [copyrightYear] = useState(() => new Date().getFullYear()) + + return ( +
+
+
+ + +
+
+
+
+ +
+
+
+

+ {t(($) => $['ipAccessDenied.title'])} +

+
+

{t(($) => $['ipAccessDenied.description'])}

+ {clientIp && ( +

+ $['ipAccessDenied.ipAddress']} + ns="share" + values={{ ip: clientIp }} + components={{ + ip: ( + + ), + }} + /> +

+ )} +
+
+
+
+
+ © {copyrightYear} LangGenius, Inc. All rights reserved. +
+
+
+ ) +} diff --git a/web/features/webapp-ip-access/boundary.tsx b/web/features/webapp-ip-access/boundary.tsx new file mode 100644 index 00000000000000..8430fc13f5322a --- /dev/null +++ b/web/features/webapp-ip-access/boundary.tsx @@ -0,0 +1,36 @@ +'use client' + +import type { PropsWithChildren } from 'react' +import { useAtomValueRawSync } from 'jotai/react' +import { useEffect } from 'react' +import { usePathname, useSearchParams } from '@/next/navigation' +import AccessRestricted from './access-restricted' +import { + captureIpAccessScope, + getIpAccessScopeKey, + ipAccessDeniedAtom, + ipAccessStore, +} from './state' + +export default function IpAccessBoundary({ children }: PropsWithChildren) { + const pathname = usePathname() + const searchParams = useSearchParams() + const search = searchParams.toString() + const denial = useAtomValueRawSync(ipAccessDeniedAtom, { store: ipAccessStore }) + const scopeKey = getIpAccessScopeKey( + pathname, + search, + typeof window === 'undefined' ? undefined : window.location.origin, + ) + + // Stay mounted in the root layout to observe leaving public routes, even + // when the next page makes no request that would otherwise reset the scope. + useEffect(() => { + captureIpAccessScope() + }, [pathname, search]) + + if (denial && denial.scope.key === scopeKey) + return + + return children +} diff --git a/web/features/webapp-ip-access/locale-menu.tsx b/web/features/webapp-ip-access/locale-menu.tsx new file mode 100644 index 00000000000000..df20cd234750f2 --- /dev/null +++ b/web/features/webapp-ip-access/locale-menu.tsx @@ -0,0 +1,53 @@ +import type { Locale } from '@/i18n-config' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useLocale } from '@/context/i18n' +import { setLocaleOnClient } from '@/i18n-config' +import { languages } from '@/i18n-config/language' + +const supportedLanguages = languages.filter((language) => language.supported) + +export default function LocaleMenu() { + const locale = useLocale() + const selectedLanguage = supportedLanguages.find((language) => language.value === locale) + const selectedLanguageName = + selectedLanguage?.value === 'en-US' ? selectedLanguage.prompt_name : selectedLanguage?.name + + return ( + + + } + > + + {selectedLanguageName} + + + + + value={locale} + onValueChange={(value) => { + void setLocaleOnClient(value, false) + }} + > + {supportedLanguages.map((language) => ( + key={language.value} value={language.value} closeOnClick> + {language.name} + + + ))} + + + + ) +} diff --git a/web/features/webapp-ip-access/state.ts b/web/features/webapp-ip-access/state.ts new file mode 100644 index 00000000000000..27211887190770 --- /dev/null +++ b/web/features/webapp-ip-access/state.ts @@ -0,0 +1,116 @@ +import { atom, createStore } from 'jotai/vanilla' +import { env } from '@/env' +import { parseWebAppAddress } from '@/service/webapp-address' +import { resolveLoginRedirectTarget } from '@/utils/login-redirect' + +export type IpAccessScope = Readonly<{ key: string; epoch: number }> + +type IpAccessDenied = { + scope: IpAccessScope + clientIp?: string +} + +type IpAccessDeniedResponse = { + code: 'ip_access_denied' + client_ip?: unknown +} + +export const ipAccessDeniedAtom = atom(null) +export const ipAccessStore = createStore() + +let currentScope: IpAccessScope | null = null +let scopeEpoch = 0 +const handledErrors = new WeakSet() + +function withoutBasePath(pathname: string) { + const prefix = env.NEXT_PUBLIC_BASE_PATH + return prefix && (pathname === prefix || pathname.startsWith(`${prefix}/`)) + ? pathname.slice(prefix.length) || '/' + : pathname +} + +export function getIpAccessScopeKey(pathname: string, search = '', origin?: string): string | null { + const path = withoutBasePath(pathname) + const address = parseWebAppAddress(path) + if (address) return `webapp:${address.kind}:${address.code}` + + const form = /^\/form\/([^/]+)\/?$/.exec(path) + if (form) return `form:${form[1]}` + + if ( + path !== '/webapp-signin' && + !path.startsWith('/webapp-signin/') && + path !== '/webapp-reset-password' && + !path.startsWith('/webapp-reset-password/') + ) + return null + + const redirect = resolveLoginRedirectTarget(new URLSearchParams(search).get('redirect_url'), { + currentOrigin: origin, + allowSameOriginAbsolute: Boolean(origin), + }) + if (!redirect) return null + + const target = new URL(redirect.href, origin || 'https://login-redirect.invalid') + const redirectAddress = parseWebAppAddress(withoutBasePath(target.pathname)) + return redirectAddress ? `webapp:${redirectAddress.kind}:${redirectAddress.code}` : null +} + +/** Capture the route before sending a request, including its current visit. */ +export function captureIpAccessScope(): IpAccessScope | null { + if (typeof globalThis.location === 'undefined') return null + + const { pathname, search, origin } = globalThis.location + const key = getIpAccessScopeKey(pathname, search, origin) + if (key !== (currentScope?.key ?? null)) { + currentScope = key ? { key, epoch: ++scopeEpoch } : null + ipAccessStore.set(ipAccessDeniedAtom, null) + } + return currentScope +} + +export function isIpAccessScopeCurrent(scope: IpAccessScope | null) { + return scope === null || captureIpAccessScope() === scope +} + +export function hasIpAccessDenied(scope: IpAccessScope | null) { + return scope !== null && ipAccessStore.get(ipAccessDeniedAtom)?.scope === scope +} + +export function isIpAccessDeniedResponse( + status: number, + data: unknown, +): data is IpAccessDeniedResponse { + return ( + status === 403 && + typeof data === 'object' && + data !== null && + 'code' in data && + data.code === 'ip_access_denied' + ) +} + +/** A handled denial stays an error for callers, without triggering auth recovery or toasts. */ +export function handleIpAccessDenied( + status: number, + data: unknown, + scope: IpAccessScope | null, + error?: object, +) { + if (!scope || !isIpAccessDeniedResponse(status, data)) return false + + if (error) handledErrors.add(error) + if (!isIpAccessScopeCurrent(scope)) return true + + const clientIp = + typeof data.client_ip === 'string' ? data.client_ip.trim() || undefined : undefined + const previous = ipAccessStore.get(ipAccessDeniedAtom) + if (previous?.scope !== scope || (!previous.clientIp && clientIp)) + ipAccessStore.set(ipAccessDeniedAtom, { scope, clientIp }) + + return true +} + +export function isIpAccessDeniedError(error: unknown): boolean { + return typeof error === 'object' && error !== null && handledErrors.has(error) +} diff --git a/web/i18n/ar-TN/share.json b/web/i18n/ar-TN/share.json index 89437bcdc60046..bed77735aa4a58 100644 --- a/web/i18n/ar-TN/share.json +++ b/web/i18n/ar-TN/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "عذراً!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "شكراً!", + "ipAccessDenied.description": "لا يمكن الوصول إلى هذا التطبيق إلا من الشبكات المعتمدة.", + "ipAccessDenied.ipAddress": "عنوان IP الحالي {{ip}} غير موجود في قائمة العناوين المسموح بها.", + "ipAccessDenied.title": "الوصول مقيّد", "login.backToHome": "العودة إلى الصفحة الرئيسية" } diff --git a/web/i18n/de-DE/share.json b/web/i18n/de-DE/share.json index 07ec4148da2680..a6701a3ccc2d37 100644 --- a/web/i18n/de-DE/share.json +++ b/web/i18n/de-DE/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Entschuldigung!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Danke!", + "ipAccessDenied.description": "Auf diese App kann nur über genehmigte Netzwerke zugegriffen werden.", + "ipAccessDenied.ipAddress": "Ihre aktuelle IP-Adresse {{ip}} steht nicht auf der Liste zulässiger Adressen.", + "ipAccessDenied.title": "Zugriff eingeschränkt", "login.backToHome": "Zurück zur Startseite" } diff --git a/web/i18n/en-US/share.json b/web/i18n/en-US/share.json index 9ccc6adfd522ad..cf716251fa48f5 100644 --- a/web/i18n/en-US/share.json +++ b/web/i18n/en-US/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Sorry!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Thanks!", + "ipAccessDenied.description": "This app can only be accessed from approved networks.", + "ipAccessDenied.ipAddress": "Your current IP address {{ip}} isn’t on the allowlist.", + "ipAccessDenied.title": "Access restricted", "login.backToHome": "Back to Home" } diff --git a/web/i18n/es-ES/share.json b/web/i18n/es-ES/share.json index 041585cc9efa6f..e5c333703d2fe9 100644 --- a/web/i18n/es-ES/share.json +++ b/web/i18n/es-ES/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "¡Lo sentimos!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "¡Gracias!", + "ipAccessDenied.description": "Solo se puede acceder a esta aplicación desde redes aprobadas.", + "ipAccessDenied.ipAddress": "Tu dirección IP actual {{ip}} no está en la lista de permitidas.", + "ipAccessDenied.title": "Acceso restringido", "login.backToHome": "Volver a Inicio" } diff --git a/web/i18n/fa-IR/share.json b/web/i18n/fa-IR/share.json index dfcf0c4d444ed3..a9cb4ab25e9d4f 100644 --- a/web/i18n/fa-IR/share.json +++ b/web/i18n/fa-IR/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "متأسفیم!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "متشکریم!", + "ipAccessDenied.description": "این برنامه فقط از طریق شبکه‌های تأییدشده قابل دسترسی است.", + "ipAccessDenied.ipAddress": "نشانی IP فعلی شما {{ip}} در فهرست نشانی‌های مجاز نیست.", + "ipAccessDenied.title": "دسترسی محدود است", "login.backToHome": "بازگشت به خانه" } diff --git a/web/i18n/fr-FR/share.json b/web/i18n/fr-FR/share.json index c121e03e336b78..d8b753416d973c 100644 --- a/web/i18n/fr-FR/share.json +++ b/web/i18n/fr-FR/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Désolé !", "humanInput.submissionID": "submission_id : {{id}}", "humanInput.thanks": "Merci !", + "ipAccessDenied.description": "Cette application est uniquement accessible depuis les réseaux approuvés.", + "ipAccessDenied.ipAddress": "Votre adresse IP actuelle {{ip}} ne figure pas dans la liste des adresses autorisées.", + "ipAccessDenied.title": "Accès restreint", "login.backToHome": "Retour à l'accueil" } diff --git a/web/i18n/hi-IN/share.json b/web/i18n/hi-IN/share.json index 1904bea7656c2a..e3ca2b72101d0d 100644 --- a/web/i18n/hi-IN/share.json +++ b/web/i18n/hi-IN/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "क्षमा करें!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "धन्यवाद!", + "ipAccessDenied.description": "इस ऐप को केवल स्वीकृत नेटवर्क से ही एक्सेस किया जा सकता है।", + "ipAccessDenied.ipAddress": "आपका वर्तमान IP पता {{ip}} अनुमति सूची में नहीं है।", + "ipAccessDenied.title": "पहुँच प्रतिबंधित है", "login.backToHome": "होम पर वापस" } diff --git a/web/i18n/id-ID/share.json b/web/i18n/id-ID/share.json index 45178425890030..13e4c8af3533b6 100644 --- a/web/i18n/id-ID/share.json +++ b/web/i18n/id-ID/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Maaf!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Terima kasih!", + "ipAccessDenied.description": "Aplikasi ini hanya dapat diakses dari jaringan yang disetujui.", + "ipAccessDenied.ipAddress": "Alamat IP Anda saat ini {{ip}} tidak ada dalam daftar yang diizinkan.", + "ipAccessDenied.title": "Akses dibatasi", "login.backToHome": "Kembali ke Beranda" } diff --git a/web/i18n/it-IT/share.json b/web/i18n/it-IT/share.json index 6a3eff216616de..e7b78f470e2b9c 100644 --- a/web/i18n/it-IT/share.json +++ b/web/i18n/it-IT/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Spiacente!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Grazie!", + "ipAccessDenied.description": "È possibile accedere a questa app solo da reti approvate.", + "ipAccessDenied.ipAddress": "Il tuo indirizzo IP attuale {{ip}} non è nell'elenco degli indirizzi consentiti.", + "ipAccessDenied.title": "Accesso limitato", "login.backToHome": "Torna alla home" } diff --git a/web/i18n/ja-JP/share.json b/web/i18n/ja-JP/share.json index 54bf8ff507bf9f..8f18f7c8200df2 100644 --- a/web/i18n/ja-JP/share.json +++ b/web/i18n/ja-JP/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "申し訳ございません!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "ありがとうございます!", + "ipAccessDenied.description": "このアプリには、承認されたネットワークからのみアクセスできます。", + "ipAccessDenied.ipAddress": "現在の IP アドレス {{ip}} は許可リストに登録されていません。", + "ipAccessDenied.title": "アクセスが制限されています", "login.backToHome": "ホームに戻る" } diff --git a/web/i18n/ko-KR/share.json b/web/i18n/ko-KR/share.json index 84e360b9b6dca7..5c4590674d8aee 100644 --- a/web/i18n/ko-KR/share.json +++ b/web/i18n/ko-KR/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "죄송합니다!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "감사합니다!", + "ipAccessDenied.description": "이 앱은 승인된 네트워크에서만 접근할 수 있습니다.", + "ipAccessDenied.ipAddress": "현재 IP 주소 {{ip}}이(가) 허용 목록에 없습니다.", + "ipAccessDenied.title": "접근 제한", "login.backToHome": "홈으로 돌아가기" } diff --git a/web/i18n/lo-LA/share.json b/web/i18n/lo-LA/share.json index 505f9038d90044..58dd65a15cec0b 100644 --- a/web/i18n/lo-LA/share.json +++ b/web/i18n/lo-LA/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "ຂໍອະໄພ!", "humanInput.submissionID": "ລະຫັດການສົ່ງ (submission_id): {{id}}", "humanInput.thanks": "ຂອບໃຈ!", + "ipAccessDenied.description": "ແອັບນີ້ສາມາດເຂົ້າເຖິງໄດ້ຈາກເຄືອຂ່າຍທີ່ອະນຸມັດເທົ່ານັ້ນ.", + "ipAccessDenied.ipAddress": "ທີ່ຢູ່ IP ປັດຈຸບັນຂອງທ່ານ {{ip}} ບໍ່ຢູ່ໃນລາຍຊື່ທີ່ອະນຸຍາດ.", + "ipAccessDenied.title": "ຈຳກັດການເຂົ້າເຖິງ", "login.backToHome": "ກັບຄືນສູ່ໜ້າຫຼັກ" } diff --git a/web/i18n/nl-NL/share.json b/web/i18n/nl-NL/share.json index 9ccc6adfd522ad..b3b2c8abf99aeb 100644 --- a/web/i18n/nl-NL/share.json +++ b/web/i18n/nl-NL/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Sorry!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Thanks!", + "ipAccessDenied.description": "Deze app is alleen toegankelijk via goedgekeurde netwerken.", + "ipAccessDenied.ipAddress": "Je huidige IP-adres {{ip}} staat niet op de lijst met toegestane adressen.", + "ipAccessDenied.title": "Toegang beperkt", "login.backToHome": "Back to Home" } diff --git a/web/i18n/pl-PL/share.json b/web/i18n/pl-PL/share.json index a811e314d248d5..1a88cdff2f2060 100644 --- a/web/i18n/pl-PL/share.json +++ b/web/i18n/pl-PL/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Przepraszamy!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Dziękujemy!", + "ipAccessDenied.description": "Ta aplikacja jest dostępna tylko z zatwierdzonych sieci.", + "ipAccessDenied.ipAddress": "Twój bieżący adres IP {{ip}} nie znajduje się na liście dozwolonych adresów.", + "ipAccessDenied.title": "Ograniczony dostęp", "login.backToHome": "Powrót do strony głównej" } diff --git a/web/i18n/pt-BR/share.json b/web/i18n/pt-BR/share.json index 33a0a1eea0a476..4b3526e47f0420 100644 --- a/web/i18n/pt-BR/share.json +++ b/web/i18n/pt-BR/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Desculpe!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Obrigado!", + "ipAccessDenied.description": "Este aplicativo só pode ser acessado por redes aprovadas.", + "ipAccessDenied.ipAddress": "Seu endereço IP atual {{ip}} não está na lista de permissões.", + "ipAccessDenied.title": "Acesso restrito", "login.backToHome": "Voltar para a página inicial" } diff --git a/web/i18n/ro-RO/share.json b/web/i18n/ro-RO/share.json index 36db28ae48edbc..bec25bad580f38 100644 --- a/web/i18n/ro-RO/share.json +++ b/web/i18n/ro-RO/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Ne pare rău!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Mulțumim!", + "ipAccessDenied.description": "Această aplicație poate fi accesată numai din rețele aprobate.", + "ipAccessDenied.ipAddress": "Adresa IP curentă {{ip}} nu se află în lista adreselor permise.", + "ipAccessDenied.title": "Acces restricționat", "login.backToHome": "Înapoi la Acasă" } diff --git a/web/i18n/ru-RU/share.json b/web/i18n/ru-RU/share.json index bb84876703a5ad..90ea8abac14d61 100644 --- a/web/i18n/ru-RU/share.json +++ b/web/i18n/ru-RU/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Извините!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Спасибо!", + "ipAccessDenied.description": "Это приложение доступно только из одобренных сетей.", + "ipAccessDenied.ipAddress": "Ваш текущий IP-адрес {{ip}} отсутствует в списке разрешённых адресов.", + "ipAccessDenied.title": "Доступ ограничен", "login.backToHome": "Назад на главную" } diff --git a/web/i18n/sl-SI/share.json b/web/i18n/sl-SI/share.json index 9498d6b2a2f1f4..88c26bf8b66f1a 100644 --- a/web/i18n/sl-SI/share.json +++ b/web/i18n/sl-SI/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Oprostite!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Hvala!", + "ipAccessDenied.description": "Ta aplikacija je dostopna samo iz odobrenih omrežij.", + "ipAccessDenied.ipAddress": "Vaš trenutni naslov IP {{ip}} ni na seznamu dovoljenih naslovov.", + "ipAccessDenied.title": "Dostop omejen", "login.backToHome": "Nazaj na začetno stran" } diff --git a/web/i18n/th-TH/share.json b/web/i18n/th-TH/share.json index 04b69606eb1670..1fc5966175c885 100644 --- a/web/i18n/th-TH/share.json +++ b/web/i18n/th-TH/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "ขออภัย!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "ขอบคุณ!", + "ipAccessDenied.description": "แอปนี้สามารถเข้าถึงได้จากเครือข่ายที่ได้รับอนุมัติเท่านั้น", + "ipAccessDenied.ipAddress": "ที่อยู่ IP ปัจจุบันของคุณ {{ip}} ไม่อยู่ในรายการที่อนุญาต", + "ipAccessDenied.title": "การเข้าถึงถูกจำกัด", "login.backToHome": "กลับไปที่หน้าแรก" } diff --git a/web/i18n/tr-TR/share.json b/web/i18n/tr-TR/share.json index dcec44786c8ab9..98263cd2be2cf2 100644 --- a/web/i18n/tr-TR/share.json +++ b/web/i18n/tr-TR/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Üzgünüz!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Teşekkürler!", + "ipAccessDenied.description": "Bu uygulamaya yalnızca onaylanmış ağlardan erişilebilir.", + "ipAccessDenied.ipAddress": "Geçerli IP adresiniz {{ip}} izin verilenler listesinde değil.", + "ipAccessDenied.title": "Erişim kısıtlandı", "login.backToHome": "Ana Sayfaya Dön" } diff --git a/web/i18n/uk-UA/share.json b/web/i18n/uk-UA/share.json index 96c63408afb460..282ff0c8804993 100644 --- a/web/i18n/uk-UA/share.json +++ b/web/i18n/uk-UA/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Вибачте!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Дякуємо!", + "ipAccessDenied.description": "Ця програма доступна лише зі схвалених мереж.", + "ipAccessDenied.ipAddress": "Вашої поточної IP-адреси {{ip}} немає в списку дозволених адрес.", + "ipAccessDenied.title": "Доступ обмежено", "login.backToHome": "Повернутися на головну" } diff --git a/web/i18n/vi-VN/share.json b/web/i18n/vi-VN/share.json index a45a5ee4bf5e91..3e26d78e3c279e 100644 --- a/web/i18n/vi-VN/share.json +++ b/web/i18n/vi-VN/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "Xin lỗi!", "humanInput.submissionID": "submission_id: {{id}}", "humanInput.thanks": "Cảm ơn!", + "ipAccessDenied.description": "Chỉ có thể truy cập ứng dụng này từ các mạng đã được phê duyệt.", + "ipAccessDenied.ipAddress": "Địa chỉ IP hiện tại của bạn {{ip}} không nằm trong danh sách cho phép.", + "ipAccessDenied.title": "Quyền truy cập bị hạn chế", "login.backToHome": "Trở về Trang Chủ" } diff --git a/web/i18n/zh-Hans/share.json b/web/i18n/zh-Hans/share.json index 5e4918072febe1..7000bba52f561a 100644 --- a/web/i18n/zh-Hans/share.json +++ b/web/i18n/zh-Hans/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "抱歉!", "humanInput.submissionID": "提交 ID: {{id}}", "humanInput.thanks": "谢谢!", + "ipAccessDenied.description": "此应用仅允许从已批准的网络访问。", + "ipAccessDenied.ipAddress": "您当前的 IP 地址 {{ip}} 不在允许列表中。", + "ipAccessDenied.title": "访问受限", "login.backToHome": "返回首页" } diff --git a/web/i18n/zh-Hant/share.json b/web/i18n/zh-Hant/share.json index c406a147cd7236..df7a687380ed0b 100644 --- a/web/i18n/zh-Hant/share.json +++ b/web/i18n/zh-Hant/share.json @@ -54,5 +54,8 @@ "humanInput.sorry": "抱歉!", "humanInput.submissionID": "提交 ID: {{id}}", "humanInput.thanks": "謝謝!", + "ipAccessDenied.description": "此應用程式僅允許從已核准的網路存取。", + "ipAccessDenied.ipAddress": "您目前的 IP 位址 {{ip}} 不在允許清單中。", + "ipAccessDenied.title": "存取受限", "login.backToHome": "返回首頁" } diff --git a/web/public/illustrations/ip-access-denied.png b/web/public/illustrations/ip-access-denied.png new file mode 100644 index 0000000000000000000000000000000000000000..9987f78473a55b03659676a5925364f1521c0566 GIT binary patch literal 10590 zcmb7qc|4Ts`#;Btibx7!MvhXpq?j_smZXx9wWPtM7+V;OtWn02p{$WT+t{}uF_dLw z$&#^#?0fco``tt5bI$pF&*$~~{{ArLd7k^e=DF|tTHe?Dx_s5|C?7e@b(n^R=E%(( z*Y48L(Eb2_oCoQ_ofjw041j-!p5C};M?*t5PW_`TbiQj&LvxDe<~0Q^?8BMp{%nYD z=-8@MN5=<;rxq4$L#G|S(+ZyMd~@cg@5L>SN`zEn=_{^~(xU-aJwwPhY>wFnLr=e- zm@q1ts0y3*{c*sTVVI%95L(Mk`x57&dOL_`x)XnsjBhfHPL3E{({k0-p6Om5a*=Iq zRsGrb@JXh2`cGNybaeKY0Pf3=`N&b$I|A=yUE!r`gNy?lx=>*w*k*d+N$SK4(RV6| zmOY;#jutG)^*Y_p;iRRcoLrk8cc$*&T$U%D^T|+W3nGe)?|O7GSDS;6ewao|o;iqa zSl)-tYXSpDqIli6=GP<5&1c)#2drJE+;N&IcV4FJX19|j3$@**+B1zVzD-S|$;>5@ zppiL5BKyj87aPp7`}m|Mer?{z#)3Ao^)!QZU#S!lK9gcL9 za#^oOGJkZKIc(q6*?BN_d-_Rbn5cf6mWNJ`+#K&_Cz)$IzQ4@b5xX&w!X3>x?!(KX zU!@wj{X6GmKZi7fMdo&q`)0@7Xq2`0T%)y{9nriat1Cvv`EeA?5{+>xv!BwjGLkla zS-?S8KOkTdQdn46Gxi&Nkj!6`tK_3#%ITFX6%oxy9Nnoe-k_%xxr{L^!H230nWcuXC7?%8vZ4Amk#{Uv5OFW0|xJ#uTX)^gDnnj6&LRL zKb`Jr7GLg5(OFVrU<&3WLm^d(Gg}@;dD5SAtyOTPYBR4%=D2|92PR2Z{q!Q>Hwa2Nm4bnhr>Ucb5OPf^Q*F zcXSU>7cdvgh{kiR?hUGvK@NMj*zYauKO4k~Im)uYe@z-`T0?yiTFoX`Gz+Sgb^?WG z9?TV52VY3-nh?!dS9Em*Mt&nA(F>b#%ME;){h7<`6x+>@YJL7bPzvrtCJZyYksURc zp(q^Tq@jtU>+}Kp7DuPIw^EM-d-wi#7h;T0xufoM>>b`eRv?W`7Q7stbr|l$us$Hi z(e~#Fg9EHRk~npcdY^UHMPn#N3jLjb{1kLp*~fv@{0i)K99?qEW*f9w{Pqlcq3ua&@z_ttg*?tO?HfDPiUlty}AWkNazKA;{SD~3t)ui?G}Q{6uH>U6+gjnlve z!G4X?up83QcrY8%?%mtJNZq~4MpNKfCS2?|+qd4tec9@T+|~fgVr>0r!{hwc`HK*m zW|@T+vlpB}bn~_q50#3?Vw@j4_PegO>T+33@Thb_<-8?AowznN1k3GSxnB zMl%&3I1j_Sd#+D}I9K%D$LVt4ou5 z=Z$F5B-lvh{>93j?I*H}Z@YZi&Z0YvDPXhTCf1}kNI5MGe;*SNH$P^FIU0E3%~nM6 zcT9;8*lpJ)md#Z)ndP>vCmN#dR&@P#O)^IPBv%#bv`+1$fL_z1+%ix7PJjQakoUw^nZbi`wCAp&3WHjh4VsyD)=$D@r(W z|8a3;hWUg1Us%u;G$&FQcQP9r<)$C)#&&2(^eIEM`<3ZEY=?!w!Bqt+`!&hqU`Ch`TQI@T+EsR9d=_9KE5Xc8x`DL#%lws!c(aGmd9nl%9_s)j(Uw zVY_o!txURScw$Jq_4R}i%VatJ&@mlJdfVTwGF^X5=OA1Q3%z1LsWo7{@GW)+v+{|7 zS0wZK=+yg;Y-DVc0g;v-ZWWMT{T(J|b{BcgM}ZT|1PofZ{v{*F=axj}u^yrGl~#$} zqHT*~O*p%;ucBqq=yK=HnIcB(!pCrnulm+y+WNTp0UR&;SO26T$MQ{swe4AVx!8_t zxOH?ApeEQ?h6Y7lMa%1$BjYbzf7Cj(~L;F!IqGMTiHn9W}n-$1^99Tl8!uc4sLi zY5BW_crL@Mx=3h2Ccn4Xx+<&=HfXAkBXhjqeDS)*gX=;DQ;fh08nyf+#z}xj)}^>$ zLZJt4y!OWWt06H$)t|d2rFmfffMG+eHWxUL+UFUvX;$Ivlq-w6*^}DQF#C*1v4i3V z`2%RVZ7NLDdam%9UqPnCKX24jow9r&DWmRCFre}}(+OG19juk>MbDB`_dQQYOmJh;33gaWGF;v6bcbs zAPWQIjaP6}r}NfO*P_d8wWvUctOOLAMQdpk;qO1j6=h{ib{rl_&#QE($a6=gd{VsE z%W0TetKH2{HHYdWJU&h~@3bt%-4E!kd?gUnp5ZfB>q^rgCJ7kqv^oh}gX)aOV3oQ` z&~U0rc=|D@26F@_iv^@w&i59Y>kj4U^p1lRUj#NtCU^+8?PzdUxGBH&|yxsKZqS*|Xs7PU#(Y=35nILSK{oj6c&kidM zzjW|(OnuNs`zuKf=oMbKZoHffxhG1zi6FQ=)^|7#=@GJb^dS9FUVW-6&RQxCw%&xi zC+XB*kY)&6M=C1tsMm#eoVAAzijNyGr>gGc&$Gy`#pPHhDj($*=uwXq``IDisrdXD-uhII?qAr?4tpjNMBFUEzNDq<~AbMD;~m2e;%n1A9Mc^)qa1f^WGBkETP+GakR0Vhi!AVgv@T}Q(OP&1t%t& zuFus^duY{*f#qwA^Un`mKrQa4LkKBxjVvD<@3+;;%B9pI&)mY1nMYKZ=6Tp=b4BY} zfPuKx$lsCoQ5choizKF(#Fep`xsNx!le+W~GtoM|H1REy!rT}Zd{(Va^-QFMh&uH= zYuQO`XYaJ-ed2$Nld&)%SK$LhKl{&S6HrmBDORONjgP{8KD9n(qH#?$quZG2DZ(l9 zx=MU^-`zF>fOJv6#nOn_IXYDjGiti5bk6*pAqi%7Hya4f(I@Rk5B*0DeQujGizvI{ z+Slv3_uqY!kfYh~dyvFx+-%R>Nr^D%O4V;RX&jn4+?nOl<1%xjYqAUb$L1;nGf%3k ziuOdRv9bX-BF_{DNvsw&w71ag@;mBvREi$);y_td{)9WFX4aou_C5A#%B^4@Sk#fF zYP?A*J#!37KZM_+oz^ualtB#GZsf4?JxC!sApZ;T4OH;kKyKxwlr*d?_?*EyO{)*} zZ4~BJQgH?_p4(F}Rvlo^5?=!I;DnT1XHw0*wzXaq4tEg(mA;7dL+6V7m?-zmFLcJ^ z%Zl7Gr{@4H`^!*G-|!NWbxneI&u4qcI6NX1+>65hHC!NLdX5^EO@nybJdBPWmz1c5 zp>n$8Tzh!#HafP16K6ySh3s7enT5$&hmgfjjodQoxe)Jj*iO;ghOQih6RH&hoJUh_ z0+|%ZL6JmZX#}Z?YFAVXyQPT?fk2VQo#LkQxwT`Jc)khsUtsNE(tI2}lNVu~uwl}{y~H7TY&>C*o%JN^{aAWxYv4`-XUNvB)NN=>u&6i$4Vo)vNFZXZ%1T>ov-4k4}$zw2{08Hxlt zSQ7aBoySqH_fkg_&76h0KG=321+7TG5 z+HDyU_zet?*T_dU8jMe`tVMT$%!ygjrWJN1RQEH0FSWAvlxYTkYG*VdL@ zX>;9aqclsjPH$fw^*SyoLwVYJDP=-4N>!=rEk&L8*?9b2)76bV&toddPSI<;Lh!!m}(GYWa1|0^<_zBlgw*1}`SHdoU==)O*k$_*sA5 zxigjs#Gm>t1eH*IcsufVKq`fbw^zaFS7`$q>MrwCBRoucAAo!EEA<4{XDw*pVy?MA z*R@$Z*?UTHz8ZyFs1{y(1B1rwWw)%D7p+(jw1vPWYDvc_+39Pop);tJ z13HX*2vginzN|8;9G>sAxjGvT#|^6Vrq>?7D1cm<=?Ne9wE|J#@gA-^D#@UgzvNQ6 z+`qF@lwXNsX%YzbZR>GFMz(2a*6O*uy_2%|F8BM%60sYc_DqvyH(8o#C?dU9haMm4 z0E)+b{$c5r-QH$bK{T|Vqh_$aG3xSI_St(sBN`P~4%8fxG`xXFhmkTKc67)Et$ntk zHT5jLy{~7Msa8ctW^{~$Q!e&|@vT#xLo3J0V6hu|N&6cAj?N^T9~+tUgyrD!!1K4c zYkU4P%ll9R=SKrrOEFXFo_xzhXNn z-i}cfBqXiweA1Utox>;Ji1i8o*dKW?F#vZAT@h6J{28(3dT7mTOu!;XRDqac5+brQEZgy89L1$kV1u>noGl64M z+2th6H)pk>Rf{_<*p-tUVh=?uY-25J=4!aD+uGx&Z;YrMh6uW@FK4?O6f0c6g%nZt zm*uCdA5+Bb2NVTm_4yzAw>bK{e;LnVNY{y11_mOVbW>(qF^w9!_f2Mq+=-v>FY zRVLXfMqM;gQwc|4Oh7%DpBoNr9XRoI%B_Z*G&C5d_6?PtnBb)%lxex+i*Gcd={evLp(aNk(4LRsuLi&Y^p2td2}@X$d&dk#N}I;Uk879%5(2y;V}X={!AmmOa)(=FL^y_6|iC8C|y0ZJzJCxk5pQS;6rTX;^s4{Ec`zhQFu5t^OyeN z{NHlEBAujL;{7;+2zxxCxIhJwNn)?QaOjEtH(@5m6^eSzT>^(treSz79u5zIJ*5XR z(DGIJ6%rsOzCL90nNf)~4+L)1`64uc78vTCQ17YLK4wnw|TcMC(cjIpiA~Km><+HAmfQg&;P=}uFdh2 zHtXr3#G>Q}Cy)q-bI4LpF|(n8lY0o9>M(usCnWyD@=FRhbXZLPgYubCru2V@93W2~ z)Q{TtxD#H?h2o?!w5T^EefVDh${qkOvo!sYHeqj|+&C>}emdS|qHZN&V>uz(pY+{n zEP9|dNo8PrwLf;(J=UCLLc*MI^gy_n59mM0CQ+VcebriA(;|Vac##xhpad(d1wL1sMG9!@;!gy6@0R4TiaWS!- zAPTteL@U6h1iEIc5gebCIqbhmNTz;~RkX~;cw&CXL+4Bm@zu7j`oX?7X5f#TFUWy& z5=+%ro+(K#(o>e?CTz66>@~4(Q_{bLwCXE8Nwny?*#EX+=c8~>I^vT0unLC*orq%A zAV3wD7_=Z1&DdA=N&pG;Z9M831o_dMyQNJ`AqF!m)l?#CmzT~{fvKdCRxKZ+9ow+P zBj;ASlG$>cG&~iqI@O_C1+d+jBmmmA*?va-!qttOadwF8yiU=|qJFoJOYQN>YCY$; zExr$_2uaIr%~gI7b%-LG-r+r$Z_*NOVlxvgM&q=0{W=r8k=B~cx#=D0yXX15Z#6TM zQ}HjnVT>i!1A&O{d_FkUK zoK(4oz5kR$4#Mmgm{gAvMnF0DU+2g*wLiV5X(l47RHwddPe?>>BP1ocl?okuuzF;}1Gy+h8 ztdS#sS=jM)qjEWi>COjXjT-g}P4wGf%-v+Lwv?7@n!f*`8_v2T9I&8J#4k}Fct4$Z z9N34z`he?`!qoGrHDwOi_qPF$)buWSaGj~?9dT$R7vRMOG73lKJFj|eQSB%-188Yl;G>zq{@)5qOxG5fWYUb+-kNyfc&MVTc8<_M$o#o8S zNPp2h{zZyaW|+YzLGHS}{i_Npg*aF+L*No}I@rot5)f`PQWrW`C!GBPxjm^nP`26^b=lh5|DwJ{gH?EpoV#r4a=ZSbeyS2F zY&~5ah<&u!UA5F->U8PBM+ec;`5Nwlk#Hn4pbUPB+C`2s$RqbcM9Ej$W%Ym}!H>P( z#B`C(E{dq>;sD^x?F36?-ZiE<1ICKV#Q5^Xpax}#lkvj9VZ>BZbCIA_?_nLL0}w3 zT5E8t6|CPs=1>)ggRg*upUxLVNlp=mBrP3|2AIW#I#`SzPoRN*1MJfcGi41(p&vwB zE?-pI4{zx(jp{tqd_p4za}9{@^!{C+Xz^B6fX(>4anht`Ou*R?=2>r+Nx^YWhjaUf zgqZiC(n}{s>N_#Vw|jloPu^-~)j~Yr{+U(viH7FjB()ddx(c;kW6@h;zp~!s?zR&g z5l*h#IoBg_gqU`%H+e=7whmZ@V|zAx=K43LK*(F>o{YpV^g?yv;uASyvyR_wloOTo zT)ma4!q>uDJFG1oj&%*zpLn|1oiKJwxLRO0cY2TP1g1O4TFI=alUxvo5t{F*Q?-~h|5OiaAfsJos%ofDZ^U3^ou6Lq#i z{f9nd|KRo2OdSJOXSBaIM&g5?7TmaVpe5cSzzFB}K^d3#ZI6fF+$vrU|6X(!YI0NZ z#a3+xyZ>Y=LH4cn@#e>D&Pn{<5GbUsozo#TVr)zzO_f@x=EubH7V20E0wU626gP%e zrJSV{JK`6#f01jbbY)y*V$Gsm@oy{sb>e|>{HFr}x4|juU)XN4Tt^7#FI(=;bJWs~ zE;u5Mfcsfk_TUqSjMW!k=JM;>yx@Gr#{f7t!o2ei;XdVl%YZ*_te9HHx%{6pj@d`_ zR!p{V`hD{#_(EYPC)^L6DTu-18G*9hK*?XC*E>1Al2Ul8AXaI}q|^LXlkBENXZ{nl z;;F2UVX$1a_{y3Y;>`Nu*w}8r7S6ZpQIQYFSV3l`-2nS zp2!~I5@4r6Cb@1t#I(`bB$t&J}CR zhf(l`raxq~F%j9Ls~3sZ{ZJ}q+C7vrxnG%Td|us?Xn%Q8J^M_2C5Ihw5-0pXO^W84 z>ARdOeeb&}A~4NYXye|U>%T!w3k7BAAe6y)t^=uDraOMv+we&%>xmP;Dx&~QKDFco zNNIlP_28vuKot-)JkT)pB--sDOq=E0kG~BBX*#mJFbR}RL1ZPY*I3BQorXN1owCcc z9UdBp2x3hFa)sC41T5tt1s4hF6o&ZEKS-81mRUB+yaHF34Q1y>?wini^Rf*HT^T}<^ z&pmJ=+)v+wv+Gh1XgS7q@`mamw1cc@Dp}_NZ4X%7uVyzjiDPjbaju}@;F*u8H&HdY za8;l9_GQ2rZ-h{!W-=X3$;+}h>a#4dvZdBi>!&SixhV4I`605(SjYs8hm{d6{Wzb4 z$&{IK_9%_K`vALyJc~wApv?09BU1zYz`56cujONF%oCWm3@APX@y>q@H>-2*)!yr7`TGN#CJv|A68x`0 z9KSFKwR_}+=@_Hrejfur^Z#gP_(yNUf4#_JH!L(Nz==;p+!h!h_E<&jzL21n>h(gm z^v+CMkJr>(Pb^O|XEpBA1q~P8Eq>w?dPpQo?AGwvF*!cfh86{c&KWr!UqBh+HfPJ- zck=7^I!vgWQ1c!IFapfF4-s`+!1nyHv!g(R(%{ z?t4(YTc8^WbpO^oWW|{|?$69AAdZ&-)t#Po+R8_cFHrqqd#4ZPoP5k=%;OC37jcpL zz5NfKO&Dgsp+$_vsL&?+BQw>UaJvscXfI~i>*fKiEvio0AE_M~|9tRs{f}AAj{TOO zzV(Ft63w2S9i4G`i&*>Nm5AfC0XDz%F+4L4@0Ky4uTVX-8Prk&~dA z1btY3%D4A-?fiKU&_o7K+z3=87h`D*3_ld*=C+(;$hR{h;~@k7$Svat*@as7!?9~s zXFqq4Rtm2d@=1SK`Pthuewxm-WX-CdRMv86Fs%4XMatYAlO-*`Y3T zpVm1(35jv57G&M=W5mkZUu8ob2t&~Ur2#K~8=<}+! z>J$qEwYx3iPD(z|r^|36KrKg%4W)|rUd}S0pm2qEciz3hVkTOwLN}Xq*?DW8{6yMe z+Vnlo-+Jtd&(ITz;0;|@I#Gd5Sr5zLNRP9N+E-r8?QHHscP3(Yo?s`E!apaegnted z*2P|61Z_q}X4Cw<>$Rnbl`6JfnXTUb?II`9uC@B#-pzlNc4x{aZMat!m2b~H9c_5~ zM>No>qjl>`DRPh)mxc6!*UHdCtG%n2-6dCcX;|Ns6YP@BzJM{z7$rT7ruActqmiu% a&2_UUmPRqTVJPr~=BCn}YgtzfJ^v3RZn|~= literal 0 HcmV?d00001 diff --git a/web/service/__tests__/base-request.spec.ts b/web/service/__tests__/base-request.spec.ts index 423232f1069d2e..192e43a425a106 100644 --- a/web/service/__tests__/base-request.spec.ts +++ b/web/service/__tests__/base-request.spec.ts @@ -45,7 +45,8 @@ vi.mock('../refresh-token', () => ({ refreshAccessTokenOrReLogin: mocks.refreshAccessTokenOrReLogin, })) -vi.mock('../webapp-address', () => ({ +vi.mock('../webapp-address', async (importOriginal) => ({ + ...(await importOriginal()), getWebAppPublicApiPath: (_address: unknown, path: string) => path, resolveWebAppAddress: mocks.resolveWebAppAddress, })) diff --git a/web/service/__tests__/ip-access-denied.spec.ts b/web/service/__tests__/ip-access-denied.spec.ts new file mode 100644 index 00000000000000..4c1f5e0e5f1a5f --- /dev/null +++ b/web/service/__tests__/ip-access-denied.spec.ts @@ -0,0 +1,339 @@ +import { toast } from '@langgenius/dify-ui/toast' +import { waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { + captureIpAccessScope, + ipAccessDeniedAtom, + ipAccessStore, + isIpAccessDeniedError, +} from '@/features/webapp-ip-access/state' +// oxlint-disable-next-line no-restricted-imports -- This integration spec exercises public WebApp transport error handling. +import { request, sseGet, ssePost, upload } from '../base' +import * as webAppAuth from '../webapp-auth' + +const refreshAccessTokenOrReLogin = vi.hoisted(() => vi.fn()) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { error: vi.fn() }, +})) + +vi.mock('../refresh-token', () => ({ refreshAccessTokenOrReLogin })) + +const denial = { + code: 'ip_access_denied', + message: 'Your IP address is not allowed.', + status: 403, + client_ip: '2001:db8::12', +} + +const createResponse = (data: unknown, status = 403) => + new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }) + +const createPendingResponse = () => { + let resolve!: (response: Response) => void + const promise = new Promise((resolveResponse) => { + resolve = resolveResponse + }) + return { promise, resolve } +} + +const createXhr = (data: unknown, status = 403) => + ({ + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(function (this: { onreadystatechange?: () => void }) { + this.onreadystatechange?.() + }), + status, + response: data, + readyState: 4, + upload: {}, + withCredentials: false, + responseType: '', + }) as unknown as XMLHttpRequest + +const transitionWhileRequestIsPending = async (transition: 'denied' | 'navigated') => { + if (transition === 'denied') { + vi.mocked(globalThis.fetch).mockResolvedValueOnce(createResponse(denial)) + const error = await request('/parameters', {}, { isPublicAPI: true }).catch( + (caught: unknown) => caught, + ) + expect(isIpAccessDeniedError(error)).toBe(true) + } else { + window.history.replaceState({}, '', '/environment/chat/another-app') + captureIpAccessScope() + } +} + +describe('WebApp IP access denial transport', () => { + beforeEach(() => { + vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureIpAccessScope() + window.history.replaceState({}, '', '/chat/ip-restricted-app') + }) + + afterEach(() => { + vi.restoreAllMocks() + window.history.replaceState({}, '', '/') + captureIpAccessScope() + }) + + it.each(['/site', '/webapp/access-mode', '/login/status', '/passport'])( + 'handles denied initialization request %s without consuming its body or changing the URL', + async (path) => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(createResponse(denial)) + const url = window.location.href + let error: unknown + + try { + await request(path, {}, { isPublicAPI: true }) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(Response) + expect(isIpAccessDeniedError(error)).toBe(true) + expect(ipAccessStore.get(ipAccessDeniedAtom)?.clientIp).toBe(denial.client_ip) + await expect((error as Response).json()).resolves.toEqual(denial) + expect(fetch).toHaveBeenCalledOnce() + expect(window.location.href).toBe(url) + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it('handles a denied HITL submission without a WebApp address', async () => { + window.history.replaceState({}, '', '/form/ip-restricted-form') + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(createResponse(denial)) + + const error = await request( + '/form/human_input/ip-restricted-form', + { method: 'POST', body: { action: 'approve' } }, + { isPublicAPI: true }, + ).catch((caught: unknown) => caught) + + expect(isIpAccessDeniedError(error)).toBe(true) + expect(toast.error).not.toHaveBeenCalled() + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + }) + + it('does not publish a late rejection for the previous App', async () => { + const pending = createPendingResponse() + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pending.promise) + const response = request('/site', {}, { isPublicAPI: true }).catch((error: unknown) => error) + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledOnce()) + + window.history.replaceState({}, '', '/chat/another-app') + captureIpAccessScope() + pending.resolve(createResponse(denial)) + + expect(isIpAccessDeniedError(await response)).toBe(true) + expect(ipAccessStore.get(ipAccessDeniedAtom)).toBeNull() + expect(toast.error).not.toHaveBeenCalled() + }) + + describe('concurrent authorization responses', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/environment/chat/ip-restricted-app') + }) + + it.each([ + { transition: 'denied' as const, status: 401, code: 'unauthorized' }, + { transition: 'navigated' as const, status: 401, code: 'unauthorized' }, + { transition: 'denied' as const, status: 403, code: 'web_app_access_denied' }, + { transition: 'navigated' as const, status: 403, code: 'web_app_access_denied' }, + { transition: 'denied' as const, status: 401, code: null }, + { transition: 'navigated' as const, status: 401, code: null }, + ])( + 'ignores late JSON $status/$code auth recovery after the page is $transition', + async ({ transition, status, code }) => { + const pending = createPendingResponse() + const fetch = vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pending.promise) + const beginRecovery = vi.spyOn(webAppAuth, 'beginWebAppAuthorizationRecovery') + const clearPassport = vi.spyOn(webAppAuth, 'clearWebAppPassport') + const response = request('/meta', {}, { isPublicAPI: true }).catch( + (error: unknown) => error, + ) + await waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + + await transitionWhileRequestIsPending(transition) + const url = window.location.href + const currentDenial = ipAccessStore.get(ipAccessDeniedAtom) + pending.resolve( + code + ? createResponse({ code, message: 'Late authorization error' }, status) + : new Response('not JSON', { status }), + ) + + expect(await response).toBeInstanceOf(Response) + expect(window.location.href).toBe(url) + expect(ipAccessStore.get(ipAccessDeniedAtom)).toBe(currentDenial) + expect(beginRecovery).not.toHaveBeenCalled() + expect(clearPassport).not.toHaveBeenCalled() + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each(['denied', 'navigated'] as const)( + 'does not complete auth recovery from a successful response after the page is %s', + async (transition) => { + const pending = createPendingResponse() + const fetch = vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pending.promise) + const completeRecovery = vi.spyOn(webAppAuth, 'completeWebAppAuthorizationRecovery') + const response = request('/site', {}, { isPublicAPI: true }) + await waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + + await transitionWhileRequestIsPending(transition) + pending.resolve(createResponse({ result: 'ok' }, 200)) + + await expect(response).resolves.toEqual({ result: 'ok' }) + expect(completeRecovery).not.toHaveBeenCalled() + }, + ) + + it.each([ + { transition: 'denied' as const, stream: ssePost, name: 'POST' }, + { transition: 'navigated' as const, stream: ssePost, name: 'POST' }, + { transition: 'denied' as const, stream: sseGet, name: 'GET' }, + { transition: 'navigated' as const, stream: sseGet, name: 'GET' }, + ])( + 'settles a late SSE $name authorization failure after the page is $transition', + async ({ transition, stream }) => { + const pending = createPendingResponse() + vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(pending.promise) + const beginRecovery = vi.spyOn(webAppAuth, 'beginWebAppAuthorizationRecovery') + const onError = vi.fn() + const onNotifyError = vi.fn() + await stream('/chat-messages', {}, { isPublicAPI: true, onError, onNotifyError }) + + await transitionWhileRequestIsPending(transition) + const url = window.location.href + pending.resolve(createResponse({ code: 'unauthorized', message: 'Late stream error' }, 401)) + + await waitFor(() => + expect(onError).toHaveBeenCalledExactlyOnceWith('Late stream error', 'unauthorized'), + ) + expect(window.location.href).toBe(url) + expect(beginRecovery).not.toHaveBeenCalled() + expect(onNotifyError).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each([ + { transition: 'denied' as const, status: 401 }, + { transition: 'navigated' as const, status: 401 }, + { transition: 'denied' as const, status: 500 }, + { transition: 'navigated' as const, status: 500 }, + ])( + 'silently aborts late XHR $status errors after the page is $transition', + async ({ transition, status }) => { + const beginRecovery = vi.spyOn(webAppAuth, 'beginWebAppAuthorizationRecovery') + const xhr = createXhr({ code: 'unauthorized', message: 'Late upload error' }, status) + xhr.send = vi.fn() + const response = upload({ xhr, data: new FormData() }, true).catch( + (error: unknown) => error, + ) + + await transitionWhileRequestIsPending(transition) + const url = window.location.href + xhr.onreadystatechange?.(new Event('readystatechange')) + + expect(await response).toMatchObject({ name: 'AbortError' }) + expect(window.location.href).toBe(url) + expect(beginRecovery).not.toHaveBeenCalled() + }, + ) + }) + + it.each([ + { status: 403, code: 'web_app_disabled', isPublicAPI: true }, + { status: 503, code: 'policy_unavailable', isPublicAPI: true }, + { status: 403, code: 'ip_access_denied', isPublicAPI: false }, + ])( + 'preserves ordinary errors for $status/$code with public API $isPublicAPI', + async ({ status, code, isPublicAPI }) => { + const data = { code, status, message: `Unchanged ${code}` } + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(createResponse(data, status)) + + const error = await request('/site', { method: 'POST' }, { isPublicAPI }).catch( + (caught: unknown) => caught, + ) + + expect(error).toBeInstanceOf(Response) + expect(isIpAccessDeniedError(error)).toBe(false) + expect(ipAccessStore.get(ipAccessDeniedAtom)).toBeNull() + await expect((error as Response).json()).resolves.toEqual(data) + expect(toast.error).toHaveBeenCalledWith(data.message) + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + }, + ) + + it.each([ + { name: 'POST', stream: ssePost, path: '/chat-messages' }, + { name: 'GET', stream: sseGet, path: '/workflow/run-id/events' }, + ])( + 'settles a denied SSE $name before reading a stream without duplicate notifications', + async ({ stream, path }) => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(createResponse(denial)) + const onData = vi.fn() + const onError = vi.fn() + const onCompleted = vi.fn() + const onNotifyError = vi.fn() + const url = window.location.href + + await stream(path, {}, { isPublicAPI: true, onData, onError, onCompleted, onNotifyError }) + + await waitFor(() => { + expect(onError).toHaveBeenCalledExactlyOnceWith(denial.message, denial.code) + }) + expect(onCompleted).not.toHaveBeenCalled() + expect(onData).not.toHaveBeenCalled() + expect(onNotifyError).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + expect(fetch).toHaveBeenCalledOnce() + expect(window.location.href).toBe(url) + }, + ) + + it('settles a denied stream through its completion callback when it has no error callback', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(createResponse(denial)) + const onCompleted = vi.fn() + + await ssePost('/chat-messages', {}, { isPublicAPI: true, onCompleted }) + + await waitFor(() => expect(onCompleted).toHaveBeenCalledExactlyOnceWith(true, denial.message)) + expect(toast.error).not.toHaveBeenCalled() + }) + + it.each([ + { route: '/chat/ip-restricted-app', path: '/files/upload' }, + { route: '/form/ip-restricted-form', path: '/human-input-forms/files' }, + ])( + 'marks denied XHR uploads on $route for the uploader to suppress duplicate errors', + async ({ route, path }) => { + window.history.replaceState({}, '', route) + const xhr = createXhr(denial) + + await expect(upload({ xhr, data: new FormData() }, true, path)).rejects.toBe(xhr) + + expect(isIpAccessDeniedError(xhr)).toBe(true) + expect(refreshAccessTokenOrReLogin).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it('preserves unrelated XHR errors', async () => { + const xhr = createXhr({ code: 'file_too_large', message: 'File too large' }, 413) + + await expect(upload({ xhr, data: new FormData() }, true)).rejects.toBe(xhr) + + expect(isIpAccessDeniedError(xhr)).toBe(false) + }) +}) diff --git a/web/service/__tests__/share-human-input-upload.spec.ts b/web/service/__tests__/share-human-input-upload.spec.ts index 4fd7ac47aa1e9d..68c108e150d949 100644 --- a/web/service/__tests__/share-human-input-upload.spec.ts +++ b/web/service/__tests__/share-human-input-upload.spec.ts @@ -1,3 +1,5 @@ +import { captureIpAccessScope, handleIpAccessDenied } from '@/features/webapp-ip-access/state' + const mockPostPublic = vi.hoisted(() => vi.fn()) const mockUpload = vi.hoisted(() => vi.fn()) @@ -21,6 +23,10 @@ vi.mock('../webapp-auth', () => ({ describe('human input form upload services', () => { beforeEach(() => { vi.clearAllMocks() + mockPostPublic.mockReset() + mockUpload.mockReset() + window.history.replaceState({}, '', '/') + captureIpAccessScope() vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-06T00:00:00Z')) }) @@ -118,4 +124,106 @@ describe('human input form upload services', () => { expect(formData.get('url')).toBe('https://example.com/file.txt') expect(response).toEqual(expect.objectContaining({ id: 'remote-file-1' })) }) + + it('should cancel local upload before requesting a token when the form is already denied', async () => { + const { uploadHumanInputFormLocalFile } = await import('../share') + window.history.replaceState({}, '', '/form/already-denied') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope()) + const onErrorCallback = vi.fn() + const onSuccessCallback = vi.fn() + + await uploadHumanInputFormLocalFile({ + formToken: 'already-denied', + file: new File(['content'], 'test.txt', { type: 'text/plain' }), + onProgressCallback: vi.fn(), + onSuccessCallback, + onErrorCallback, + }) + + expect(mockPostPublic).not.toHaveBeenCalled() + expect(mockUpload).not.toHaveBeenCalled() + expect(onSuccessCallback).not.toHaveBeenCalled() + expect(onErrorCallback).toHaveBeenCalledWith(expect.objectContaining({ name: 'AbortError' })) + }) + + it.each(['IP denial', 'navigation'])( + 'should cancel the pending upload chain after %s without uploading to the new scope', + async (interruption) => { + const { uploadHumanInputFormRemoteFileInfo } = await import('../share') + const formToken = `pending-${interruption}` + window.history.replaceState({}, '', `/form/${formToken}`) + let finishToken!: (value: { upload_token: string; expires_at: number }) => void + mockPostPublic.mockReturnValueOnce( + new Promise((resolve) => { + finishToken = resolve + }), + ) + + const uploadResult = uploadHumanInputFormRemoteFileInfo( + formToken, + 'https://example.com/file.txt', + ).catch((error: unknown) => error) + expect(mockPostPublic).toHaveBeenCalledTimes(1) + + if (interruption === 'IP denial') + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope()) + else window.history.replaceState({}, '', '/form/another-form') + + finishToken({ + upload_token: 'late-token', + expires_at: Math.floor(Date.now() / 1000) + 60, + }) + + expect(await uploadResult).toMatchObject({ name: 'AbortError' }) + expect(mockUpload).not.toHaveBeenCalled() + }, + ) + + it('should treat a token error received after navigation as upload cancellation', async () => { + const { uploadHumanInputFormRemoteFileInfo } = await import('../share') + window.history.replaceState({}, '', '/form/failed-token') + let rejectToken!: (error: Response) => void + mockPostPublic.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectToken = reject + }), + ) + + const uploadResult = uploadHumanInputFormRemoteFileInfo( + 'failed-token', + 'https://example.com/file.txt', + ).catch((error: unknown) => error) + window.history.replaceState({}, '', '/form/another-form') + rejectToken(new Response(null, { status: 503 })) + + expect(await uploadResult).toMatchObject({ name: 'AbortError' }) + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('should preserve a normal token failure while the form is still active', async () => { + const { uploadHumanInputFormRemoteFileInfo } = await import('../share') + window.history.replaceState({}, '', '/form/unavailable-token') + const tokenError = new Response(null, { status: 503 }) + mockPostPublic.mockRejectedValueOnce(tokenError) + + await expect( + uploadHumanInputFormRemoteFileInfo('unavailable-token', 'https://example.com/file.txt'), + ).rejects.toBe(tokenError) + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('should preserve an IP denial returned by the token request', async () => { + const { uploadHumanInputFormRemoteFileInfo } = await import('../share') + window.history.replaceState({}, '', '/form/token-denied') + const tokenError = new Response(null, { status: 403 }) + mockPostPublic.mockImplementationOnce(async () => { + handleIpAccessDenied(403, { code: 'ip_access_denied' }, captureIpAccessScope(), tokenError) + throw tokenError + }) + + await expect( + uploadHumanInputFormRemoteFileInfo('token-denied', 'https://example.com/file.txt'), + ).rejects.toBe(tokenError) + expect(mockUpload).not.toHaveBeenCalled() + }) }) diff --git a/web/service/base.ts b/web/service/base.ts index 4d0d2f9c9f98d9..c9b65d99bb055a 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -1,5 +1,6 @@ import type { FetchOptionType, ResponseError } from './fetch' import type { MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/base/chat/chat/type' +import type { IpAccessScope } from '@/features/webapp-ip-access/state' import type { VisionFile } from '@/types/app' import type { DataSourceNodeCompletedResponse, @@ -39,6 +40,12 @@ import { PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME, } from '@/config' +import { + captureIpAccessScope, + handleIpAccessDenied, + hasIpAccessDenied, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import { asyncRunSafe } from '@/utils' import { isClient } from '@/utils/client' import { resolveLoginRedirectTarget } from '@/utils/login-redirect' @@ -55,6 +62,9 @@ import { const TIME_OUT = 100000 +const canRecoverWebAppAuthorization = (scope: IpAccessScope | null) => + isIpAccessScopeCurrent(scope) && !hasIpAccessDenied(scope) + const isWebAppAuthorizationEndpoint = (url: string) => /\/(?:login(?:\/|\?|$)|passport(?:\?|$))/.test(url) @@ -155,12 +165,26 @@ const handlePublicStreamResponseError = async ( onError: IOnError | undefined, onNotifyError: IOnError | undefined, silent: boolean | undefined, + ipAccessScope: IpAccessScope | null, + onCompleted: IOnCompleted | undefined, ) => { let data: { code?: string; message?: string; reason?: string } | undefined try { data = (await response.clone().json()) as typeof data } catch {} + if (data && handleIpAccessDenied(response.status, data, ipAccessScope, response)) { + const message = data.message || 'IP access denied' + if (onError) onError(message, 'ip_access_denied') + else onCompleted?.(true, message) + return + } + if (!canRecoverWebAppAuthorization(ipAccessScope)) { + const message = data?.message || 'Server Error' + if (onError) onError(message, typeof data?.code === 'string' ? data.code : undefined) + else onCompleted?.(true, message) + return + } if (data) { if (handleWebAppAuthorizationError(data)) return if (data.code === 'web_sso_auth_required' || data.code === 'unauthorized') { @@ -594,6 +618,7 @@ export const upload = async ( url?: string, searchParams?: string, ): Promise => { + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null const address = resolveWebAppAddress() const shareCode = address?.code const publicApiPrefix = PUBLIC_API_PREFIX @@ -628,6 +653,14 @@ export const upload = async ( if (xhr.readyState === 4) { if (xhr.status === 201) resolve(xhr.response) else { + if (handleIpAccessDenied(xhr.status, xhr.response, ipAccessScope, xhr)) { + reject(xhr) + return + } + if (isPublicAPI && !canRecoverWebAppAuthorization(ipAccessScope)) { + reject(new DOMException('The upload is no longer active.', 'AbortError')) + return + } if ( isPublicAPI && (xhr.status === 401 || xhr.status === 403) && @@ -689,6 +722,7 @@ export const ssePost = async ( onUnhandledEvent, silent, } = otherOptions + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null const abortController = new AbortController() // No need to get token from localStorage, cookies will be sent automatically @@ -727,7 +761,14 @@ export const ssePost = async ( if (!/^[23]\d{2}$/.test(String(res.status))) { if (res.status === 401 || (res.status === 403 && isPublicAPI)) { if (isPublicAPI) { - void handlePublicStreamResponseError(res, onError, onNotifyError, silent) + void handlePublicStreamResponseError( + res, + onError, + onNotifyError, + silent, + ipAccessScope, + onCompleted, + ) } else { refreshAccessTokenOrReLogin(TIME_OUT) .then(() => { @@ -850,6 +891,7 @@ export const sseGet = async ( onUnhandledEvent, silent, } = otherOptions + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null const abortController = new AbortController() const baseOptions = getBaseOptions() @@ -882,7 +924,14 @@ export const sseGet = async ( if (!/^[23]\d{2}$/.test(String(res.status))) { if (res.status === 401 || (res.status === 403 && isPublicAPI)) { if (isPublicAPI) { - void handlePublicStreamResponseError(res, onError, onNotifyError, silent) + void handlePublicStreamResponseError( + res, + onError, + onNotifyError, + silent, + ipAccessScope, + onCompleted, + ) } else { refreshAccessTokenOrReLogin(TIME_OUT) .then(() => { @@ -1072,10 +1121,16 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe try { const otherOptionsForBaseFetch = otherOptions || {} const { isPublicAPI = false, silent } = otherOptionsForBaseFetch + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null const [err, resp] = await asyncRunSafe(baseFetch(url, options, otherOptionsForBaseFetch)) if (err === null) { const address = resolveWebAppAddress() - if (isPublicAPI && address?.kind === 'environment' && !isWebAppAuthorizationEndpoint(url)) + if ( + isPublicAPI && + canRecoverWebAppAuthorization(ipAccessScope) && + address?.kind === 'environment' && + !isWebAppAuthorizationEndpoint(url) + ) completeWebAppAuthorizationRecovery(address) return resp } @@ -1083,7 +1138,10 @@ export const request = async (url: string, options = {}, otherOptions?: IOthe if (errResp.status === 401 || (errResp.status === 403 && isPublicAPI)) { if (!isClient) return Promise.reject(err) - const [parseErr, errRespData] = await asyncRunSafe(errResp.json()) + const [parseErr, errRespData] = await asyncRunSafe(errResp.clone().json()) + if (!parseErr && handleIpAccessDenied(errResp.status, errRespData, ipAccessScope, errResp)) + return Promise.reject(err) + if (isPublicAPI && !canRecoverWebAppAuthorization(ipAccessScope)) return Promise.reject(err) if (parseErr) { if (errResp.status === 401) { discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index c6dc9b0f519ba6..bd24aa56235b7b 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -1,5 +1,6 @@ import type { AfterResponseHook, BeforeRequestHook, Hooks } from 'ky' import type { IOtherOptions } from './base' +import type { IpAccessScope } from '@/features/webapp-ip-access/state' import Cookies from 'js-cookie' import ky, { HTTPError } from 'ky' import { @@ -13,6 +14,12 @@ import { PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME, } from '@/config' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessDeniedResponse, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import { shouldSuppressAppDeletionErrorToast } from './app-deletion' import { clearRequestErrorToasts, notifyRequestError } from './request-error-toast' import { getWebAppPublicApiPath, resolveWebAppAddress } from './webapp-address' @@ -50,6 +57,7 @@ export type ResponseError = { error?: string reason?: string status: number + client_ip?: string } const createResponseFromHTTPError = (error: HTTPError): Response => { @@ -69,7 +77,10 @@ const createResponseFromHTTPError = (error: HTTPError): Response => { }) } -const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook => { +const afterResponseErrorCode = ( + otherOptions: IOtherOptions, + ipAccessScope: IpAccessScope | null, +): AfterResponseHook => { return async ({ request, response }) => { if (!/^[23]\d{2}$/.test(String(response.status))) { let errorData: ResponseError | null = null @@ -81,6 +92,11 @@ const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook response.status !== 401 && errorData && !otherOptions.silent && + !( + ipAccessScope && + (!isIpAccessScopeCurrent(ipAccessScope) || hasIpAccessDenied(ipAccessScope)) + ) && + !(ipAccessScope && isIpAccessDeniedResponse(response.status, errorData)) && !shouldSuppressAppDeletionErrorToast(request.url, response.status) const errorMessage = errorData?.message || errorData?.error @@ -158,6 +174,7 @@ async function base( fetchCompat = false, request, } = otherOptions + const ipAccessScope = isPublicAPI ? captureIpAccessScope() : null const headers = new Headers(headersFromProps || {}) @@ -189,7 +206,10 @@ async function base( ...(baseHooks.beforeRequest || []), isPublicAPI && beforeRequestPublicWithCode, ].filter((h): h is BeforeRequestHook => Boolean(h)), - afterResponse: [...(baseHooks.afterResponse || []), afterResponseErrorCode(otherOptions)], + afterResponse: [ + ...(baseHooks.afterResponse || []), + afterResponseErrorCode(otherOptions, ipAccessScope), + ], }, }) diff --git a/web/service/share.ts b/web/service/share.ts index e398cf4a9fc58e..a4a4e06b7093ff 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -5,6 +5,12 @@ import type { ChatConfig } from '@/app/components/base/chat/types' import type { AccessMode } from '@/models/access-control' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' import { WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' +import { + captureIpAccessScope, + hasIpAccessDenied, + isIpAccessDeniedError, + isIpAccessScopeCurrent, +} from '@/features/webapp-ip-access/state' import { del as consoleDel, get as consoleGet, @@ -522,7 +528,21 @@ const uploadHumanInputFormFile = async ( formData: FormData, onProgress?: (e: ProgressEvent) => void, ) => { - const uploadToken = await getHumanInputFormUploadToken(formToken) + const scope = captureIpAccessScope() + const ensureCanUpload = () => { + if (!isIpAccessScopeCurrent(scope) || hasIpAccessDenied(scope)) + throw new DOMException('The form upload is no longer active.', 'AbortError') + } + ensureCanUpload() + + let uploadToken: string + try { + uploadToken = await getHumanInputFormUploadToken(formToken) + } catch (error) { + if (!isIpAccessDeniedError(error)) ensureCanUpload() + throw error + } + ensureCanUpload() return upload( { diff --git a/web/utils/is-abort-error.ts b/web/utils/is-abort-error.ts new file mode 100644 index 00000000000000..a2fbaa5416b89e --- /dev/null +++ b/web/utils/is-abort-error.ts @@ -0,0 +1,5 @@ +export function isAbortError(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError' + ) +} From 892c6ee1c08010edf6e67aa9f786ef07fb933834 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 17 Sep 2026 10:30:09 +0800 Subject: [PATCH 032/108] fix(web): let editors inspect network policies without workspace mutations --- .../account-setting/__tests__/index.spec.tsx | 30 ++- .../header/account-setting/index.tsx | 4 +- .../ip-policies-page/__tests__/index.spec.tsx | 219 +++++++++++++++++- .../ip-policies-page/index.tsx | 61 +++-- .../ip-policies-page/policy-dialog.tsx | 151 ++++++++---- .../ip-policies-page/policy-item.tsx | 101 ++++---- .../__tests__/permissions.spec.tsx | 58 +++++ web/features/network-access/permissions.ts | 17 ++ 8 files changed, 516 insertions(+), 125 deletions(-) create mode 100644 web/features/network-access/__tests__/permissions.spec.tsx create mode 100644 web/features/network-access/permissions.ts diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index fffc93383b6b39..424e9f4e895278 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -571,20 +571,46 @@ describe('AccountSetting', () => { ).toBeInTheDocument() }) - it('should hide IP Policies for workspace members', () => { + it('opens IP Policies read-only for editors', () => { mockConsoleState.current = { ...baseConsoleState, + currentWorkspace: { ...baseConsoleState.currentWorkspace, role: 'editor' }, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, } renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IP_POLICIES }) + expect(screen.getByRole('button', { name: 'common.settings.ipPolicies' })).toBeInTheDocument() expect( - screen.queryByRole('button', { name: 'common.settings.ipPolicies' }), + screen.getByText('deployments.studio.accessControl.emptyPoliciesTitle'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'common.settings.ipPolicyAddEntry' }), ).not.toBeInTheDocument() }) + it.each(['normal', 'dataset_operator'])( + 'hides IP Policies and rejects direct entry for %s', + (role) => { + mockConsoleState.current = { + ...baseConsoleState, + currentWorkspace: { ...baseConsoleState.currentWorkspace, role }, + isCurrentWorkspaceManager: false, + isCurrentWorkspaceOwner: false, + } + + renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.IP_POLICIES }) + + expect( + screen.queryByRole('button', { name: 'common.settings.ipPolicies' }), + ).not.toBeInTheDocument() + expect( + screen.queryByText('deployments.studio.accessControl.emptyPoliciesTitle'), + ).not.toBeInTheDocument() + }, + ) + it('should change active tab when clicking on menu item', () => { // Arrange renderAccountSetting({ onTabChange: mockOnTabChange }) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 083a4a8ee446bc..06f8c063eacc23 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -22,6 +22,7 @@ import { isCurrentWorkspaceDatasetOperatorAtom, isCurrentWorkspaceManagerAtom, } from '@/context/workspace-state' +import { canReadNetworkAccessAtom } from '@/features/network-access/permissions' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { consoleQuery } from '@/service/console' @@ -74,8 +75,7 @@ export default function AccountSetting({ systemFeatures.deployment_edition === 'CLOUD' && !isCurrentWorkspaceDatasetOperator const canViewWorkflowLogArchives = systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager - const canViewIpPolicies = - systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager + const canViewIpPolicies = useAtomValue(canReadNetworkAccessAtom) const activeMenu = (() => { if (activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && !canViewIpPolicies) return ACCOUNT_SETTING_TAB.MEMBERS diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index cc20e736d53d99..be4ca58c350fc9 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' import { createNetworkAccessGroupFixture, seedNetworkAccessGroups, @@ -14,6 +15,7 @@ const translations = vi.hoisted(() => ({ 'operation.delete': 'Delete', 'operation.edit': 'Edit', 'operation.moreActionsFor': 'More actions for {{name}}', + 'operation.save': 'Save', 'settings.ipPolicies': 'IP Policies', 'settings.ipPoliciesDescription': 'Reusable rules that control which IP addresses or ranges can access your apps', @@ -51,6 +53,211 @@ vi.mock('react-i18next', async () => { }) describe('IpPoliciesPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([true, false])( + 'lets an editor read the complete policy without management actions (entitled: %s)', + async (entitled) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled, + groups: [ + createNetworkAccessGroupFixture({ + allowed_cidrs: ['203.0.113.42/32', '198.51.100.0/24', '2001:db8::/32'], + used_by_count: 1, + app_ids: ['app-a'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'editor' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'More actions for Internal Network' }), + ).not.toBeInTheDocument() + await user.tab() + expect(screen.getByRole('button', { name: 'Internal Network' })).toHaveFocus() + await user.keyboard('{Enter}') + + const dialog = screen.getByRole('dialog', { name: 'Internal Network' }) + expect(within(dialog).getByText('203.0.113.42/32')).toBeInTheDocument() + expect(within(dialog).getByText('198.51.100.0/24')).toBeInTheDocument() + expect(within(dialog).getByText('2001:db8::/32')).toBeInTheDocument() + expect(within(dialog).getByRole('link', { name: 'Support Bot' })).toBeInTheDocument() + expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { name: /Create|Save|Delete|Add/ }), + ).not.toBeInTheDocument() + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it.each(['edit', 'delete'] as const)( + 'allows an entitled administrator to %s a policy', + async (action) => { + const user = userEvent.setup() + const group = createNetworkAccessGroupFixture({ version: 7 }) + const requests: Array<{ method: string; url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + const body = await request.text() + requests.push({ + method: request.method, + url: request.url, + body: body ? JSON.parse(body) : null, + }) + return request.method === 'DELETE' + ? new Response(null, { status: 204 }) + : new Response(JSON.stringify({ group, entitled: true }), { + headers: { 'content-type': 'application/json' }, + }) + }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [group] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'admin' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click( + screen.getByRole('menuitem', { name: action === 'edit' ? 'Edit' : 'Delete' }), + ) + if (action === 'edit') { + await user.clear(screen.getByRole('textbox', { name: 'Name' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Office Network') + await user.click(screen.getByRole('button', { name: 'Save' })) + } else { + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete' }), + ) + } + + await waitFor(() => + expect(requests).toContainEqual( + action === 'edit' + ? { + method: 'PUT', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups/group-1', + body: { + name: 'Office Network', + description: '', + allowed_cidrs: group.allowed_cidrs, + expected_version: 7, + }, + } + : { + method: 'DELETE', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups/group-1?expected_version=7', + body: null, + }, + ), + ) + }, + ) + + it.each(['create', 'edit', 'delete'] as const)( + 'stops %s when manager permission is revoked', + async (action) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'admin' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + if (action === 'create') { + await user.click(screen.getByRole('button', { name: 'Add' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New policy') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + } else { + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click( + screen.getByRole('menuitem', { name: action === 'edit' ? 'Edit' : 'Delete' }), + ) + if (action === 'edit') + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' draft') + } + + await act(async () => { + seedCurrentWorkspaceQuery(queryClient, { role: 'editor' }) + }) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^(Create|Save|Delete)$/ }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + if (action === 'edit') { + expect(screen.getByRole('dialog', { name: 'Internal Network' })).toBeInTheDocument() + expect(screen.queryByText('Internal Network draft')).not.toBeInTheDocument() + } else { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + } + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it.each(['normal', 'dataset_operator'] as const)( + 'does not query or render policies for %s', + (role) => { + const queryClient = createConsoleQueryClient() + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect(screen.queryByRole('heading', { name: 'IP Policies' })).not.toBeInTheDocument() + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + it('opens the new policy dialog from Add', async () => { const user = userEvent.setup() const queryClient = createConsoleQueryClient() @@ -59,7 +266,7 @@ describe('IpPoliciesPage', () => { , - { queryClient }, + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, ) expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() @@ -92,7 +299,7 @@ describe('IpPoliciesPage', () => { , - { queryClient }, + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, ) await user.click(screen.getByRole('button', { name: 'Add' })) @@ -125,7 +332,7 @@ describe('IpPoliciesPage', () => { , - { queryClient }, + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, ) expect(screen.getByText('Internal Network')).toBeInTheDocument() @@ -174,7 +381,7 @@ describe('IpPoliciesPage', () => { , - { queryClient }, + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, ) await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) @@ -221,7 +428,7 @@ describe('IpPoliciesPage', () => { , - { queryClient }, + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, ) await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index 939d10cb7735e3..b18d2c9c216747 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -3,6 +3,7 @@ import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { Button } from '@langgenius/dify-ui/button' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,6 +13,10 @@ import { pricingQueryParamName, pricingQueryParser, } from '@/app/components/billing/pricing/query-params' +import { + canManageNetworkAccessPoliciesAtom, + canReadNetworkAccessAtom, +} from '@/features/network-access/permissions' import { consoleQuery } from '@/service/console' import { IpPolicyDialog } from './policy-dialog' import { @@ -24,7 +29,10 @@ import { policyUpdatedColClassName, } from './policy-item' -type DialogState = { mode: 'create' } | { mode: 'edit'; group: NetworkAccessGroupResponse } | null +type DialogState = + | { mode: 'create' } + | { mode: 'edit' | 'view'; group: NetworkAccessGroupResponse } + | null function IpPoliciesListSkeleton() { const { t } = useTranslation() @@ -51,8 +59,11 @@ function IpPoliciesListSkeleton() { export default function IpPoliciesPage() { const { t } = useTranslation() const queryClient = useQueryClient() + const canReadPolicies = useAtomValue(canReadNetworkAccessAtom) + const canManagePolicies = useAtomValue(canManageNetworkAccessPoliciesAtom) const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) const groupsQuery = consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ + enabled: canReadPolicies, retry: false, }) const { data, isPending, isError } = useQuery(groupsQuery) @@ -65,7 +76,8 @@ export default function IpPoliciesPage() { const [dialogState, setDialogState] = useState(null) const groups = data?.groups ?? [] const entitled = data?.entitled === true - const editingGroup = dialogState?.mode === 'edit' ? dialogState.group : null + const canMutate = canManagePolicies && entitled + const selectedGroup = dialogState && dialogState.mode !== 'create' ? dialogState.group : null const isSaving = createGroup.isPending || updateGroup.isPending const refreshGroupsAfterConflict = async (groupId?: string) => { @@ -76,7 +88,7 @@ export default function IpPoliciesPage() { } const handleOpenCreate = () => { - if (isError) return + if (!canManagePolicies || isPending || isError) return if (!entitled) { void setPricing('open') return @@ -84,6 +96,8 @@ export default function IpPoliciesPage() { setDialogState({ mode: 'create' }) } + if (!canReadPolicies) return null + return (
@@ -95,10 +109,12 @@ export default function IpPoliciesPage() { {t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' })}

- + {canManagePolicies && ( + + )}
{isPending && } @@ -139,42 +155,47 @@ export default function IpPoliciesPage() { setDialogState({ mode: 'edit', group: nextGroup })} + canMutate={canMutate} + onView={(group) => setDialogState({ mode: 'view', group })} + onEdit={(group) => { + if (!canMutate) return + setDialogState({ mode: 'edit', group }) + }} /> ))} )} - {dialogState && ( + {dialogState && (dialogState.mode !== 'create' || canMutate) && ( { if (!open) setDialogState(null) }} onSubmit={(payload) => { - if (editingGroup) { + if (!canMutate || isSaving || dialogState.mode === 'view') return + if (selectedGroup) { updateGroup.mutate( { - params: { group_id: editingGroup.id }, + params: { group_id: selectedGroup.id }, body: { name: payload.name, - description: editingGroup.description ?? '', + description: selectedGroup.description ?? '', allowed_cidrs: payload.allowed_cidrs, - expected_version: editingGroup.version, + expected_version: selectedGroup.version, }, }, { onSuccess: () => setDialogState(null), onError: (error) => { if (getNetworkAccessErrorStatus(error) !== 409) return - void refreshGroupsAfterConflict(editingGroup.id) + void refreshGroupsAfterConflict(selectedGroup.id) }, }, ) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index 1db4de9ad53d2c..886234f4412ca1 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -30,7 +30,7 @@ type IpPolicyDialogSubmit = { type IpPolicyDialogProps = { open: boolean - mode: 'create' | 'edit' + mode: 'create' | 'edit' | 'view' currentIp?: string initialName?: string initialEntries?: readonly string[] @@ -73,12 +73,16 @@ export function IpPolicyDialog({
- {mode === 'edit' - ? t(($) => $['settings.ipPolicyEditTitle'], { ns: 'common' }) - : t(($) => $['settings.ipPolicyNewTitle'], { ns: 'common' })} + {mode === 'view' + ? initialName + : mode === 'edit' + ? t(($) => $['settings.ipPolicyEditTitle'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyNewTitle'], { ns: 'common' })} - {t(($) => $['settings.ipPolicyDialogDescription'], { ns: 'common' })} + {mode === 'view' + ? t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyDialogDescription'], { ns: 'common' })}
- { - event.preventDefault() - if (!canSubmit) return - onSubmit?.({ - name: name.trim(), - allowed_cidrs: collectAllowedCidrs(entryValues), - }) - }} - > - - {t(($) => $['settings.ipPolicyName'], { ns: 'common' })} - $['settings.ipPolicyNamePlaceholder'], { ns: 'common' })} - onChange={(event) => setName(event.currentTarget.value)} - /> - - - - - {mode === 'edit' && usedByCount > 0 && ( + {mode === 'view' ? ( +
+
+
+
+ {t(($) => $['settings.ipPolicyName'], { ns: 'common' })} +
+
+ {initialName} +
+
+
+
+ {t(($) => $['settings.ipPolicyAllowlist'], { ns: 'common' })} +
+
+
    + {initialEntries?.map((entry) => ( +
  • + {entry} +
  • + ))} +
+
+
+ {usedByCount > 0 && ( +
+
+ {t(($) => $['settings.ipPolicyUsedByLabel'], { ns: 'common' })} +
+
+ +
+
+ )} +
+
+ +
+
+ ) : ( + { + event.preventDefault() + if (!canSubmit || isPending) return + onSubmit?.({ + name: name.trim(), + allowed_cidrs: collectAllowedCidrs(entryValues), + }) + }} + > - - {t(($) => $['settings.ipPolicyUsedByLabel'], { ns: 'common' })} - - + {t(($) => $['settings.ipPolicyName'], { ns: 'common' })} + $['settings.ipPolicyNamePlaceholder'], { ns: 'common' })} + onChange={(event) => setName(event.currentTarget.value)} + /> - )} -
- - -
- + + + {mode === 'edit' && usedByCount > 0 && ( + + + {t(($) => $['settings.ipPolicyUsedByLabel'], { ns: 'common' })} + + + + )} + +
+ + +
+ + )} ) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx index a87da162e545c7..faaea41b499410 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -10,6 +10,7 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' import { DropdownMenu, DropdownMenuContent, @@ -35,10 +36,11 @@ export const policyActionsColClassName = 'flex w-8 shrink-0 items-center justify type PolicyItemProps = { group: NetworkAccessGroupResponse canMutate: boolean + onView: (group: NetworkAccessGroupResponse) => void onEdit: (group: NetworkAccessGroupResponse) => void } -export function PolicyItem({ group, canMutate, onEdit }: PolicyItemProps) { +export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps) { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() const [menuOpen, setMenuOpen] = useState(false) @@ -75,9 +77,16 @@ export function PolicyItem({ group, canMutate, onEdit }: PolicyItemProps) { return (
-

- {group.name} -

+
+ +

{group.allowed_cidrs.length}

@@ -88,49 +97,51 @@ export function PolicyItem({ group, canMutate, onEdit }: PolicyItemProps) { {formatTimeFromNow(Date.parse(group.updated_at))}

- - $['operation.moreActionsFor'], { - ns: 'common', - name: group.name, - })} - className="data-popup-open:bg-state-base-hover" + {canMutate && ( + + $['operation.moreActionsFor'], { + ns: 'common', + name: group.name, + })} + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + + { + if (!canMutate) return + onEdit(group) + }} > - - - } - /> - - { - if (!canMutate) return - onEdit(group) - }} - > - {t(($) => $['operation.edit'], { ns: 'common' })} - - - { - if (!canMutate) return - setConfirmDelete(true) - setMenuOpen(false) - }} - > - {t(($) => $['operation.delete'], { ns: 'common' })} - - - + {t(($) => $['operation.edit'], { ns: 'common' })} + + + { + if (!canMutate) return + setConfirmDelete(true) + setMenuOpen(false) + }} + > + {t(($) => $['operation.delete'], { ns: 'common' })} + + + + )}
- +
diff --git a/web/features/network-access/__tests__/permissions.spec.tsx b/web/features/network-access/__tests__/permissions.spec.tsx new file mode 100644 index 00000000000000..642ea0efb99fc2 --- /dev/null +++ b/web/features/network-access/__tests__/permissions.spec.tsx @@ -0,0 +1,58 @@ +import { act, waitFor } from '@testing-library/react' +import { useAtomValue } from 'jotai' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { renderHookWithConsoleQuery } from '@/test/console/query-data' +import { canManageNetworkAccessPoliciesAtom, canReadNetworkAccessAtom } from '../permissions' + +function useNetworkAccessPermissions() { + return { + canRead: useAtomValue(canReadNetworkAccessAtom), + canManage: useAtomValue(canManageNetworkAccessPoliciesAtom), + } +} + +describe('network access permissions', () => { + it.each([ + { role: 'owner', canRead: true, canManage: true }, + { role: 'admin', canRead: true, canManage: true }, + { role: 'editor', canRead: true, canManage: false }, + { role: 'normal', canRead: false, canManage: false }, + { role: 'dataset_operator', canRead: false, canManage: false }, + ] as const)('allows the supported operations for $role', ({ role, canRead, canManage }) => { + const { result } = renderHookWithConsoleQuery(useNetworkAccessPermissions, { + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + expect(result.current).toEqual({ canRead, canManage }) + }) + + it.each(['COMMUNITY', 'ENTERPRISE'] as const)( + 'does not grant access in %s', + (deployment_edition) => { + const { result } = renderHookWithConsoleQuery(useNetworkAccessPermissions, { + currentWorkspace: { role: 'owner' }, + systemFeatures: { deployment_edition }, + }) + expect(result.current).toEqual({ canRead: false, canManage: false }) + }, + ) + + it('waits for the authoritative workspace and responds to role changes', async () => { + vi.mocked(globalThis.fetch).mockImplementation(() => new Promise(() => {})) + const { result, queryClient } = renderHookWithConsoleQuery(useNetworkAccessPermissions, { + currentWorkspace: null, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + expect(result.current).toEqual({ canRead: false, canManage: false }) + + await act(async () => { + seedCurrentWorkspaceQuery(queryClient, { role: 'admin' }) + }) + await waitFor(() => expect(result.current).toEqual({ canRead: true, canManage: true })) + + await act(async () => { + seedCurrentWorkspaceQuery(queryClient, { role: 'editor' }) + }) + await waitFor(() => expect(result.current).toEqual({ canRead: true, canManage: false })) + }) +}) diff --git a/web/features/network-access/permissions.ts b/web/features/network-access/permissions.ts new file mode 100644 index 00000000000000..c8fc9e79c62055 --- /dev/null +++ b/web/features/network-access/permissions.ts @@ -0,0 +1,17 @@ +'use client' + +import { atom } from 'jotai' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' +import { deploymentEditionAtom } from '@/features/system-features/state' + +export const canReadNetworkAccessAtom = atom((get) => { + if (get(currentWorkspaceLoadingAtom) || get(deploymentEditionAtom) !== 'CLOUD') return false + const { id, role } = get(currentWorkspaceAtom) + return Boolean(id) && (role === 'owner' || role === 'admin' || role === 'editor') +}) + +export const canManageNetworkAccessPoliciesAtom = atom((get) => { + if (!get(canReadNetworkAccessAtom)) return false + const { role } = get(currentWorkspaceAtom) + return role === 'owner' || role === 'admin' +}) From e4d98dddd975bc02b3b8e5d542eee5c5050f7cac Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 17 Sep 2026 10:30:32 +0800 Subject: [PATCH 033/108] fix(web): align IP access controls with app capabilities and Agent access --- .../__tests__/chip-status.spec.ts | 23 +- .../__tests__/config-panel.spec.tsx | 8 +- .../access-control/__tests__/draft.spec.ts | 69 ++- .../access-control/__tests__/index.spec.tsx | 500 ++++++++++++++++-- .../__tests__/network-access.spec.ts | 62 ++- .../access-control/chip-status.ts | 19 +- .../access-control/config-panel.tsx | 37 +- .../access-control/downgrade-panel.tsx | 17 +- .../app/access-point/access-control/draft.ts | 38 +- .../app/access-point/access-control/index.tsx | 100 ++-- .../access-control/network-access.ts | 26 +- .../access-control/policy-field.tsx | 23 +- .../access-control/scope-list.tsx | 24 +- .../access-control/status-panel.tsx | 34 +- web/app/components/app/access-point/index.tsx | 12 +- .../access/__tests__/page.spec.tsx | 192 +++++++ .../agent-v2/agent-detail/access/page.tsx | 19 +- 17 files changed, 1010 insertions(+), 193 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx diff --git a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts index d9d35ac7d01e75..c2353c2216ac98 100644 --- a/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/chip-status.spec.ts @@ -1,3 +1,4 @@ +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { getAccessControlChipState, getInServiceCoverage } from '../chip-status' const assignment = { @@ -15,7 +16,10 @@ const assignment = { describe('getInServiceCoverage', () => { it('counts selected access points against every configured access point', () => { expect( - getInServiceCoverage({ webApp: true, serviceApi: true, mcp: false, trigger: true }), + getInServiceCoverage( + { webApp: true, serviceApi: true, mcp: false, trigger: true }, + ACCESS_POINT_ORDER, + ), ).toEqual({ coveredCount: 3, inServiceCount: 4 }) }) }) @@ -24,6 +28,7 @@ describe('getAccessControlChipState', () => { it('returns pro when the workspace is not entitled and has no assignment', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: false, assignment: null, }).kind, @@ -33,6 +38,7 @@ describe('getAccessControlChipState', () => { it('keeps the saved assignment visible when the workspace is not entitled', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: false, assignment, }).kind, @@ -42,6 +48,7 @@ describe('getAccessControlChipState', () => { it('returns off when the app has never been configured', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: true, assignment: null, }), @@ -51,6 +58,7 @@ describe('getAccessControlChipState', () => { it('returns paused when a saved policy is not enforcing', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: true, assignment: { ...assignment, enabled: false }, }), @@ -65,6 +73,7 @@ describe('getAccessControlChipState', () => { it('returns on when every access point is covered', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: true, assignment, }), @@ -79,6 +88,7 @@ describe('getAccessControlChipState', () => { it('returns partial when some access points are excluded', () => { expect( getAccessControlChipState({ + availableAccessPoints: ACCESS_POINT_ORDER, entitled: true, assignment: { ...assignment, @@ -93,3 +103,14 @@ describe('getAccessControlChipState', () => { }) }) }) + +describe('app capability coverage', () => { + it.each([ + { available: ['webApp', 'serviceApi', 'mcp'] as const, count: 3 }, + { available: ['webApp', 'serviceApi'] as const, count: 2 }, + ])('counts full coverage as $count of $count', ({ available, count }) => { + expect( + getAccessControlChipState({ assignment, entitled: true, availableAccessPoints: available }), + ).toMatchObject({ kind: 'on', coveredCount: count, inServiceCount: count }) + }) +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index 30195b987e4ad7..b6782ac2a97955 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { render } from '@/test/console/render' import { AccessControlConfigPanel } from '../config-panel' import { createDefaultAccessControlDraft } from '../draft' @@ -50,7 +51,7 @@ vi.mock('react-i18next', async () => { function PanelHarness({ currentIp, - initialDraft = createDefaultAccessControlDraft(), + initialDraft = createDefaultAccessControlDraft(ACCESS_POINT_ORDER), onSave = vi.fn(), }: { currentIp?: string @@ -68,6 +69,9 @@ function PanelHarness({ { render( { it('disables save when no policy is selected', () => { - expect(canSaveAccessControl({ draft: createDefaultAccessControlDraft() })).toBe(false) + expect( + canSaveAccessControl({ + availableAccessPoints: ACCESS_POINT_ORDER, + draft: createDefaultAccessControlDraft(ACCESS_POINT_ORDER), + }), + ).toBe(false) }) it('disables save when every access point is off', () => { expect( canSaveAccessControl({ + availableAccessPoints: ACCESS_POINT_ORDER, draft: { selectedPolicyId: 'policy-1', enabled: true, @@ -32,12 +43,15 @@ describe('canSaveAccessControl', () => { }) it('enables save when a policy is selected and at least one access point is on', () => { - expect(canSaveAccessControl({ draft: selectedDraft })).toBe(true) + expect( + canSaveAccessControl({ availableAccessPoints: ACCESS_POINT_ORDER, draft: selectedDraft }), + ).toBe(true) }) it('counts trigger as a selectable access point', () => { expect( canSaveAccessControl({ + availableAccessPoints: ACCESS_POINT_ORDER, draft: { selectedPolicyId: 'policy-1', enabled: true, @@ -58,14 +72,9 @@ describe('canSaveAccessControl', () => { draft: { selectedPolicyId: 'policy-1', enabled: true, - scopes: { - webApp: false, - serviceApi: false, - mcp: false, - trigger: true, - }, + scopes: { webApp: false, serviceApi: false, mcp: false, trigger: true }, }, - persistableAccessPoints: [], + availableAccessPoints: [], }), ).toBe(false) }) @@ -73,6 +82,7 @@ describe('canSaveAccessControl', () => { it('disables save when the draft matches the saved baseline', () => { expect( canSaveAccessControl({ + availableAccessPoints: ACCESS_POINT_ORDER, draft: selectedDraft, baseline: selectedDraft, }), @@ -82,6 +92,7 @@ describe('canSaveAccessControl', () => { it('allows saving a pause when a policy is already selected', () => { expect( canSaveAccessControl({ + availableAccessPoints: ACCESS_POINT_ORDER, draft: { ...selectedDraft, enabled: false }, baseline: selectedDraft, }), @@ -121,3 +132,39 @@ describe('policyIncludesIp', () => { expect(policyIncludesIp(['198.51.100.0/24'], '203.0.113.42')).toBe(false) }) }) + +describe('available access points', () => { + it.each([ + { available: ['webApp', 'serviceApi', 'mcp', 'trigger'] as const, selected: 4 }, + { available: ['webApp', 'serviceApi', 'mcp'] as const, selected: 3 }, + { available: ['webApp', 'serviceApi'] as const, selected: 2 }, + ])('defaults to $selected supported scopes', ({ available, selected }) => { + const draft = createDefaultAccessControlDraft(available) + expect(Object.values(draft.scopes).filter(Boolean)).toHaveLength(selected) + available.forEach((scope) => expect(draft.scopes[scope]).toBe(true)) + }) + + it('ignores unsupported scope differences when checking for edits', () => { + const baseline = createDefaultAccessControlDraft(['webApp', 'serviceApi']) + expect( + isAccessControlDraftEqual( + baseline, + { + ...baseline, + scopes: { ...baseline.scopes, mcp: true, trigger: true }, + }, + ['webApp', 'serviceApi'], + ), + ).toBe(true) + }) + + it('does not count an unsupported trigger selection toward a valid binding', () => { + const draft = { + ...selectedDraft, + scopes: { webApp: false, serviceApi: false, mcp: false, trigger: true }, + } + expect( + canSaveAccessControl({ draft, availableAccessPoints: ['webApp', 'serviceApi', 'mcp'] }), + ).toBe(false) + }) +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 8b1436c1d8d240..433b3dc6185c62 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -1,8 +1,15 @@ -import type { AppNetworkAccessGroupBindingResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppNetworkAccessGroupBindingResponse, + AppNetworkAccessGroupResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' -import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { + GetWorkspacesCurrentSummaryResponse, + NetworkAccessGroupResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/console' import { createNetworkAccessGroupFixture, seedAppNetworkAccessGroup, @@ -74,16 +81,6 @@ const accessControlTranslations = vi.hoisted(() => ({ 'studio.accessControl.turnOffConfirm': 'Turn off', })) -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: { appDetail: { id: string; mode: string } }) => unknown) => - selector({ - appDetail: { - id: 'app-1', - mode: 'chat', - }, - }), -})) - vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() return { @@ -105,28 +102,42 @@ const renderEntry = ({ deploymentEdition = 'CLOUD', groups = [], binding = null, + availableAccessPoints = ['webapp', 'service_api', 'mcp'], + role = 'owner', + canEditBinding = true, }: { plan?: CloudPlan deploymentEdition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' groups?: NetworkAccessGroupResponse[] binding?: AppNetworkAccessGroupBindingResponse | null + availableAccessPoints?: AppNetworkAccessGroupResponse['available_access_points'] + role?: GetWorkspacesCurrentSummaryResponse['role'] + canEditBinding?: boolean } = {}) => { const queryClient = createConsoleQueryClient() const entitled = plan === 'professional' || plan === 'team' seedNetworkAccessGroups(queryClient, { entitled, groups }) - seedAppNetworkAccessGroup(queryClient, 'app-1', { entitled, binding }) - - return renderWithConsoleQuery(, { - queryClient, - systemFeatures: { deployment_edition: deploymentEdition }, - features: plan - ? { - billing: { - subscription: { interval: 'month', plan }, - }, - } - : undefined, + seedAppNetworkAccessGroup(queryClient, 'app-1', { + entitled, + binding, + available_access_points: availableAccessPoints, }) + + return renderWithConsoleQuery( + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: deploymentEdition }, + features: plan + ? { + billing: { + subscription: { interval: 'month', plan }, + }, + } + : undefined, + }, + ) } const getChip = () => screen.getByRole('button', { name: /Access Control/ }) @@ -166,13 +177,13 @@ describe('AccessControlEntry', () => { }, }) - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() await user.click(getChip()) expect(screen.getByText('This app is no longer protected')).toBeInTheDocument() expect(screen.getByText('Access control needs the Pro plan.')).toBeInTheDocument() expect(screen.getByText('Ready to resume')).toBeInTheDocument() expect(screen.getByText('Internal Network')).toBeInTheDocument() - expect(screen.getByText('Protects 3 of 4 access points')).toBeInTheDocument() + expect(screen.getByText('Protects 3 of 3 access points')).toBeInTheDocument() expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() expect(screen.queryByRole('switch', { name: 'Restrict by IP address' })).not.toBeInTheDocument() @@ -264,10 +275,7 @@ describe('AccessControlEntry', () => { 'aria-disabled', 'true', ) - expect(screen.getByRole('switch', { name: 'Trigger' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) + expect(screen.queryByRole('switch', { name: 'Trigger' })).not.toBeInTheDocument() expect(screen.queryByText('Not enabled')).not.toBeInTheDocument() }) @@ -386,7 +394,7 @@ describe('AccessControlEntry', () => { }, }) - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() await user.click(getChip()) expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toBeChecked() @@ -415,7 +423,7 @@ describe('AccessControlEntry', () => { await user.click(screen.getByRole('button', { name: 'Turn off' })) expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() }) it('discards an unsaved first-time draft on Cancel', async () => { @@ -423,7 +431,7 @@ describe('AccessControlEntry', () => { renderEntry({ plan: 'professional' }) await user.click(getChip()) - await user.click(screen.getByRole('switch', { name: 'Trigger' })) + await user.click(screen.getByRole('switch', { name: 'Web App' })) await user.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => { @@ -431,7 +439,7 @@ describe('AccessControlEntry', () => { }) await user.click(getChip()) - expect(screen.getByRole('switch', { name: 'Trigger' })).toBeChecked() + expect(screen.getByRole('switch', { name: 'Web App' })).toBeChecked() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() }) @@ -440,8 +448,8 @@ describe('AccessControlEntry', () => { renderEntry({ plan: 'professional' }) await user.click(getChip()) - await user.click(screen.getByRole('switch', { name: 'Trigger' })) - expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + await user.click(screen.getByRole('switch', { name: 'Web App' })) + expect(screen.getByRole('switch', { name: 'Web App' })).not.toBeChecked() await user.keyboard('{Escape}') await waitFor(() => { @@ -450,7 +458,7 @@ describe('AccessControlEntry', () => { await user.click(getChip()) expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() - expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + expect(screen.getByRole('switch', { name: 'Web App' })).not.toBeChecked() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() }) @@ -482,15 +490,15 @@ describe('AccessControlEntry', () => { await waitFor(() => { expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() }) - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() await user.click(getChip()) expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() - expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() + expect(screen.queryByRole('switch', { name: 'Trigger' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() }) it('discards an unsaved edit on Cancel', async () => { @@ -626,12 +634,420 @@ describe('AccessControlEntry', () => { await waitFor(() => { expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() }) - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() await user.click(getChip()) expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('3 of 4')).toBeInTheDocument() + expect(within(getChip()).getByText('ON')).toBeInTheDocument() + }) +}) + +const capabilityCases = [ + { + name: 'workflow', + points: ['webapp', 'service_api', 'mcp', 'trigger'], + labels: ['Web App', 'Backend Service API', 'MCP Server', 'Trigger'], + }, + { + name: 'legacy and chatflow', + points: ['webapp', 'service_api', 'mcp'], + labels: ['Web App', 'Backend Service API', 'MCP Server'], + }, + { name: 'agent', points: ['webapp', 'service_api'], labels: ['Web App', 'Backend Service API'] }, +] satisfies { + name: string + points: AppNetworkAccessGroupResponse['available_access_points'] + labels: string[] +}[] + +const createBinding = ( + overrides: Partial = {}, +): AppNetworkAccessGroupBindingResponse => ({ + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api', 'mcp'], + version: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, +}) + +describe('supported access points and binding permission', () => { + beforeEach(() => vi.clearAllMocks()) + + it.each(capabilityCases)( + 'shows and saves exactly the $name capabilities', + async ({ points, labels }) => { + const user = userEvent.setup() + const requests: Record[] = [] + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.method === 'PUT') { + const payload = await request.json() + requests.push(payload) + return Response.json({ + binding: createBinding({ ...payload, version: 1 }), + available_access_points: points, + effective_enabled: true, + }) + } + return Response.json({ + tenant_id: 'workspace-1', + app_id: 'app-1', + entitled: true, + binding: createBinding({ access_points: points }), + available_access_points: points, + effective_enabled: true, + }) + }) + try { + renderEntry({ + plan: 'professional', + availableAccessPoints: points, + groups: [createNetworkAccessGroupFixture()], + }) + await user.click(getChip()) + expect(screen.getAllByRole('switch')).toHaveLength(labels.length) + labels.forEach((label) => expect(screen.getByRole('switch', { name: label })).toBeChecked()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(requests).toEqual([ + { enabled: true, group_id: 'group-1', access_points: points, expected_version: 0 }, + ]), + ) + } finally { + fetchSpy.mockRestore() + } + }, + ) + + it.each(capabilityCases)( + 'shows full $name coverage in saved status', + async ({ points, labels }) => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + availableAccessPoints: points, + groups: [createNetworkAccessGroupFixture()], + binding: createBinding({ access_points: points }), + }) + expect(within(getChip()).getByText('ON')).toBeInTheDocument() + await user.click(getChip()) + expect( + screen.getByText(`Protecting all ${points.length} access points in service.`), + ).toBeInTheDocument() + labels.forEach((label) => expect(screen.getByText(label)).toBeInTheDocument()) + if (!points.includes('trigger')) expect(screen.queryByText('Trigger')).not.toBeInTheDocument() + if (!points.includes('mcp')) expect(screen.queryByText('MCP Server')).not.toBeInTheDocument() + }, + ) + + it.each(capabilityCases)( + 'uses $name capabilities in the downgrade summary', + async ({ points }) => { + const user = userEvent.setup() + renderEntry({ + plan: 'sandbox', + availableAccessPoints: points, + groups: [createNetworkAccessGroupFixture()], + binding: createBinding({ access_points: points }), + }) + await user.click(getChip()) + expect( + screen.getByText(`Protects ${points.length} of ${points.length} access points`), + ).toBeInTheDocument() + }, + ) + + it('lets editors select and save an existing policy without allowing policy creation', async () => { + const user = userEvent.setup() + const { unmount } = renderEntry({ plan: 'professional', role: 'editor' }) + await user.click(getChip()) + expect(screen.queryByRole('button', { name: 'Create an IP policy' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Manage IP policies' })) + expect(mockSetSettingsDestination).toHaveBeenCalledWith('ip-policies') + unmount() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + binding: createBinding(), + available_access_points: ['webapp', 'service_api', 'mcp'], + effective_enabled: true, + }), + ) + try { + renderEntry({ + plan: 'professional', + role: 'editor', + groups: [createNetworkAccessGroupFixture()], + }) + await user.click(getChip()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()) + const [input, init] = fetchSpy.mock.calls[0]! + expect(new Request(input, init).method).toBe('PUT') + } finally { + fetchSpy.mockRestore() + } + }) + + it('preserves selected scopes and versions when pausing and resuming a binding', async () => { + const user = userEvent.setup() + const availableAccessPoints = ['webapp', 'service_api', 'mcp'] as const + let binding = createBinding({ access_points: ['webapp', 'mcp'] }) + const payloads: unknown[] = [] + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url.endsWith('/workspaces/current/network-access-groups')) + return Response.json({ + tenant_id: 'workspace-1', + entitled: true, + groups: [createNetworkAccessGroupFixture()], + }) + if (request.method === 'PUT') { + const payload = await request.json() + payloads.push(payload) + binding = createBinding({ ...payload, version: binding.version + 1 }) + return Response.json({ + binding, + available_access_points: availableAccessPoints, + effective_enabled: binding.enabled, + }) + } + return Response.json({ + tenant_id: 'workspace-1', + app_id: 'app-1', + entitled: true, + binding, + available_access_points: availableAccessPoints, + effective_enabled: binding.enabled, + }) + }) + try { + renderEntry({ plan: 'professional', groups: [createNetworkAccessGroupFixture()], binding }) + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + expect(screen.getByRole('alertdialog')).toHaveTextContent( + 'Web App, MCP Server will be reachable from any IP.', + ) + await user.click(screen.getByRole('button', { name: 'Turn off' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(payloads).toEqual([ + { + enabled: false, + group_id: 'group-1', + access_points: ['webapp', 'mcp'], + expected_version: 2, + }, + ]), + ) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(), + ) + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).not.toBeChecked() + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(payloads).toHaveLength(2)) + expect(payloads[1]).toEqual({ + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'mcp'], + expected_version: 3, + }) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(), + ) + } finally { + fetchSpy.mockRestore() + } + }) + + it('shows a paid read-only binding without a downgrade or editing controls', async () => { + const user = userEvent.setup() + const fetchSpy = vi.spyOn(globalThis, 'fetch') + try { + renderEntry({ + plan: 'professional', + canEditBinding: false, + groups: [createNetworkAccessGroupFixture()], + binding: createBinding(), + }) + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + expect(screen.queryByText('This app is no longer protected')).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Turn on Access Control' }), + ).not.toBeInTheDocument() + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + fetchSpy.mockRestore() + } + }) + + it('discards unsaved policy and scope edits when binding permission is revoked', async () => { + const user = userEvent.setup() + const rendered = renderEntry({ + plan: 'professional', + groups: [ + createNetworkAccessGroupFixture(), + createNetworkAccessGroupFixture({ id: 'group-2', name: 'Office' }), + ], + binding: createBinding(), + }) + await user.click(getChip()) + await user.click(screen.getByRole('button', { name: 'Edit' })) + await user.click(screen.getByRole('combobox', { name: 'IP Policy' })) + await user.click(screen.getByRole('option', { name: /Office/ })) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + + rendered.rerender() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + expect(screen.getByText('Protecting all 3 access points in service.')).toBeInTheDocument() + expect(screen.queryByText('Office')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + + rendered.rerender() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(getChip()) + expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByRole('combobox', { name: 'IP Policy' })).toHaveTextContent( + 'Internal Network', + ) + expect(screen.getByRole('switch', { name: 'MCP Server' })).toBeChecked() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('closes an outstanding pause confirmation when binding permission is revoked', async () => { + const user = userEvent.setup() + const rendered = renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + binding: createBinding(), + }) + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Restrict by IP address' })) + expect(screen.getByRole('alertdialog')).toBeInTheDocument() + + rendered.rerender() + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + await user.click(getChip()) + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toBeChecked() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + + rendered.rerender() + await user.click(getChip()) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(screen.getByRole('switch', { name: 'Restrict by IP address' })).toBeChecked() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + + it('keeps first-time binding controls read-only when app edits are forbidden', async () => { + const user = userEvent.setup() + renderEntry({ + plan: 'professional', + canEditBinding: false, + groups: [createNetworkAccessGroupFixture()], + }) + await user.click(getChip()) + expect(screen.getByRole('option', { name: /Internal Network/ })).toBeDisabled() + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + + it.each(['normal', 'dataset_operator'] as const)('hides the entry from workspace %s', (role) => { + renderEntry({ plan: 'professional', role }) + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + }) + + it('does not create an editable fallback when available access points are empty', () => { + renderEntry({ plan: 'professional', availableAccessPoints: [] }) + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + }) + + it('discards the previous app draft when the App ID changes', async () => { + const user = userEvent.setup() + const rendered = renderEntry({ + plan: 'professional', + groups: [createNetworkAccessGroupFixture()], + }) + seedAppNetworkAccessGroup(rendered.queryClient, 'app-2', { + available_access_points: ['webapp', 'service_api'], + }) + await user.click(getChip()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await user.click(screen.getByRole('switch', { name: 'Web App' })) + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + rendered.rerender() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await user.click(getChip()) + expect(screen.getByRole('switch', { name: 'Web App' })).toBeChecked() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(screen.queryByRole('switch', { name: 'MCP Server' })).not.toBeInTheDocument() + }) + + it.each([ + { + name: 'failed binding query', + response: () => Response.json({ message: 'Service unavailable' }, { status: 503 }), + status: 'error', + }, + { + name: 'missing capability field', + response: () => + Response.json({ + tenant_id: 'workspace-1', + app_id: 'app-1', + entitled: true, + binding: null, + effective_enabled: false, + }), + status: 'success', + }, + ])('does not enable editing after $name', async ({ response, status }) => { + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true }) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => response()) + try { + renderWithConsoleQuery(, { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + await waitFor(() => + expect( + queryClient.getQueryState( + consoleQuery.apps.byAppId.networkAccessGroup.get.queryKey({ + input: { params: { app_id: 'app-1' } }, + }), + )?.status, + ).toBe(status), + ) + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + expect(fetchSpy).toHaveBeenCalledTimes(1) + } finally { + fetchSpy.mockRestore() + } }) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts index 63c51d27717d6b..0681bfe7e3e9c3 100644 --- a/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/network-access.spec.ts @@ -1,6 +1,8 @@ +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { accessPointsFromScopes, draftFromBinding, + getAvailableAccessPoints, getNetworkAccessErrorStatus, scopesFromAccessPoints, toApiAccessPoint, @@ -31,12 +33,15 @@ describe('network access mappers', () => { it('includes every selected access point in the PUT payload', () => { expect( - accessPointsFromScopes({ - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, - }), + accessPointsFromScopes( + { + webApp: true, + serviceApi: true, + mcp: true, + trigger: true, + }, + ACCESS_POINT_ORDER, + ), ).toEqual(['webapp', 'service_api', 'mcp', 'trigger']) }) @@ -49,13 +54,13 @@ describe('network access mappers', () => { mcp: true, trigger: true, }, - ['webapp', 'service_api', 'mcp'], + ['webApp', 'serviceApi', 'mcp'], ), ).toEqual(['webapp', 'service_api', 'mcp']) }) it('creates an empty draft when the app has no binding', () => { - expect(draftFromBinding(null)).toEqual({ + expect(draftFromBinding(null, ACCESS_POINT_ORDER)).toEqual({ selectedPolicyId: null, enabled: true, scopes: { @@ -69,17 +74,20 @@ describe('network access mappers', () => { it('hydrates a draft from a bound group', () => { expect( - draftFromBinding({ - id: 'binding-1', - tenant_id: 'workspace-1', - app_id: 'app-1', - enabled: true, - group_id: 'group-1', - access_points: ['webapp', 'service_api'], - version: 3, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - }), + draftFromBinding( + { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'app-1', + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api'], + version: 3, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ['webApp', 'serviceApi'], + ), ).toEqual({ selectedPolicyId: 'group-1', enabled: true, @@ -92,3 +100,19 @@ describe('network access mappers', () => { }) }) }) + +describe('available scope mapping', () => { + it('orders supported access points independently of response order', () => { + expect(getAvailableAccessPoints(['mcp', 'webapp', 'service_api'])).toEqual([ + 'webApp', + 'serviceApi', + 'mcp', + ]) + }) + it('keeps an explicit empty capability list empty', () => { + expect(getAvailableAccessPoints([])).toEqual([]) + expect( + accessPointsFromScopes({ webApp: true, serviceApi: true, mcp: true, trigger: true }, []), + ).toEqual([]) + }) +}) diff --git a/web/app/components/app/access-point/access-control/chip-status.ts b/web/app/components/app/access-point/access-control/chip-status.ts index 8833d78767e5a5..66084fba01172d 100644 --- a/web/app/components/app/access-point/access-control/chip-status.ts +++ b/web/app/components/app/access-point/access-control/chip-status.ts @@ -1,6 +1,5 @@ import type { AccessControlDraft } from './draft' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' -import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' export type AccessControlAssignment = { policyId: string @@ -18,28 +17,33 @@ export type AccessControlChipState = { policyName?: string } -export function getInServiceCoverage(scopes: Record) { - const covered = ACCESS_POINT_ORDER.filter((scope) => scopes[scope]) +export function getInServiceCoverage( + scopes: Record, + availableAccessPoints: readonly AccessPoint[], +) { + const covered = availableAccessPoints.filter((scope) => scopes[scope]) return { coveredCount: covered.length, - inServiceCount: ACCESS_POINT_ORDER.length, + inServiceCount: availableAccessPoints.length, } } export function getAccessControlChipState({ entitled, assignment, + availableAccessPoints, }: { entitled?: boolean assignment: AccessControlAssignment | null + availableAccessPoints: readonly AccessPoint[] }): AccessControlChipState { if (!assignment) { if (entitled === false) return { kind: 'pro', coveredCount: 0, inServiceCount: 0 } return { kind: 'off', coveredCount: 0, inServiceCount: 0 } } - const coverage = getInServiceCoverage(assignment.scopes) + const coverage = getInServiceCoverage(assignment.scopes, availableAccessPoints) if (!assignment.enabled) { return { @@ -50,7 +54,10 @@ export function getAccessControlChipState({ } return { - kind: coverage.coveredCount === coverage.inServiceCount ? 'on' : 'partial', + kind: + coverage.inServiceCount > 0 && coverage.coveredCount === coverage.inServiceCount + ? 'on' + : 'partial', policyName: assignment.policyName, ...coverage, } diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index b4c00ee2bcd49e..716cb7008b6610 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -2,6 +2,8 @@ import type { FormEvent } from 'react' import type { AccessControlDraft, AccessControlPolicy } from './draft' +import type { AccessControlAppIcon } from './index' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' @@ -16,7 +18,10 @@ type AccessControlConfigPanelProps = { draft: AccessControlDraft policies: readonly AccessControlPolicy[] currentIp?: string - persistableAccessPoints?: readonly string[] + availableAccessPoints: readonly AccessPoint[] + appIcon: AccessControlAppIcon + readOnly?: boolean + canManagePolicies: boolean baseline?: AccessControlDraft showBack?: boolean saving?: boolean @@ -32,7 +37,10 @@ export function AccessControlConfigPanel({ draft, policies, currentIp, - persistableAccessPoints, + availableAccessPoints, + appIcon, + readOnly = false, + canManagePolicies, baseline, showBack = false, saving = false, @@ -46,10 +54,8 @@ export function AccessControlConfigPanel({ const { t } = useTranslation() const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const hasSelectedPolicy = Boolean(draft.selectedPolicyId) - const hasPersistableSelection = persistableAccessPoints - ? persistableAccessPoints.length > 0 - : hasSelectedAccessPoint(draft) - const canSave = canSaveAccessControl({ draft, baseline, persistableAccessPoints }) + const hasPersistableSelection = hasSelectedAccessPoint(draft, availableAccessPoints) + const canSave = !readOnly && canSaveAccessControl({ draft, baseline, availableAccessPoints }) const handleSubmit = (event: FormEvent) => { event.preventDefault() @@ -90,9 +96,12 @@ export function AccessControlConfigPanel({ policies={policies} selectedPolicyId={draft.selectedPolicyId} currentIp={currentIp} + readOnly={readOnly} + canManagePolicies={canManagePolicies} onCreatePolicy={onCreatePolicy} onManagePolicies={onManagePolicies} onSelectPolicy={(policyId) => { + if (readOnly) return onDraftChange({ ...draft, selectedPolicyId: policyId }) }} /> @@ -107,7 +116,13 @@ export function AccessControlConfigPanel({ ? t(($) => $['studio.accessControl.applyToHelpSelected'], { ns: 'deployments' }) : t(($) => $['studio.accessControl.applyToHelp'], { ns: 'deployments' })}

- + {hasSelectedPolicy && draft.enabled && !hasPersistableSelection && (

{t(($) => $['studio.accessControl.selectAccessPoint'], { ns: 'deployments' })} @@ -119,9 +134,11 @@ export function AccessControlConfigPanel({ - + {!readOnly && ( + + )}

diff --git a/web/app/components/app/access-point/access-control/downgrade-panel.tsx b/web/app/components/app/access-point/access-control/downgrade-panel.tsx index b948f97666061b..8506f865491e1e 100644 --- a/web/app/components/app/access-point/access-control/downgrade-panel.tsx +++ b/web/app/components/app/access-point/access-control/downgrade-panel.tsx @@ -1,13 +1,12 @@ 'use client' import type { AccessControlAssignment } from './chip-status' +import type { AccessControlAppIcon } from './index' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { PopoverDescription, PopoverTitle } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' -import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' -import { useStore as useAppStore } from '@/app/components/app/store' import AppIcon from '@/app/components/base/app-icon' import { getInServiceCoverage } from './chip-status' @@ -19,17 +18,20 @@ const SCOPE_ICONS: Record, string> = { type AccessControlDowngradePanelProps = { assignment: AccessControlAssignment + appIcon: AccessControlAppIcon + availableAccessPoints: readonly AccessPoint[] onTurnOn: () => void } export function AccessControlDowngradePanel({ assignment, + appIcon, + availableAccessPoints, onTurnOn, }: AccessControlDowngradePanelProps) { const { t } = useTranslation() - const appInfo = useAppStore((state) => state.appDetail) - const coverage = getInServiceCoverage(assignment.scopes) - const protectedScopes = ACCESS_POINT_ORDER.filter((scope) => assignment.scopes[scope]) + const coverage = getInServiceCoverage(assignment.scopes, availableAccessPoints) + const protectedScopes = availableAccessPoints.filter((scope) => assignment.scopes[scope]) const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) const turnOn = t(($) => $['studio.accessControl.turnOn'], { ns: 'deployments' }) const pro = t(($) => $['studio.accessControl.proBadge'], { ns: 'deployments' }) @@ -64,10 +66,7 @@ export function AccessControlDowngradePanel({ key={scope} size="tiny" decorative - iconType={appInfo?.icon_type} - icon={appInfo?.icon} - background={appInfo?.icon_background ?? undefined} - imageUrl={appInfo?.icon_url} + {...appIcon} className="rounded-sm bg-util-colors-orange-orange-100" /> ) : ( diff --git a/web/app/components/app/access-point/access-control/draft.ts b/web/app/components/app/access-point/access-control/draft.ts index a9bde917d5d467..49d6ebb61be736 100644 --- a/web/app/components/app/access-point/access-control/draft.ts +++ b/web/app/components/app/access-point/access-control/draft.ts @@ -1,6 +1,5 @@ import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' -import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' export type AccessControlPolicy = Pick @@ -10,40 +9,49 @@ export type AccessControlDraft = { enabled: boolean } -export const createDefaultAccessControlDraft = (): AccessControlDraft => ({ +export const createDefaultAccessControlDraft = ( + availableAccessPoints: readonly AccessPoint[], +): AccessControlDraft => ({ selectedPolicyId: null, enabled: true, scopes: { - webApp: true, - serviceApi: true, - mcp: true, - trigger: true, + webApp: availableAccessPoints.includes('webApp'), + serviceApi: availableAccessPoints.includes('serviceApi'), + mcp: availableAccessPoints.includes('mcp'), + trigger: availableAccessPoints.includes('trigger'), }, }) -export function isAccessControlDraftEqual(left: AccessControlDraft, right: AccessControlDraft) { +export function isAccessControlDraftEqual( + left: AccessControlDraft, + right: AccessControlDraft, + availableAccessPoints: readonly AccessPoint[], +) { if (left.selectedPolicyId !== right.selectedPolicyId) return false if (left.enabled !== right.enabled) return false - return ACCESS_POINT_ORDER.every((scope) => left.scopes[scope] === right.scopes[scope]) + return availableAccessPoints.every((scope) => left.scopes[scope] === right.scopes[scope]) } -export function hasSelectedAccessPoint(draft: AccessControlDraft) { - return ACCESS_POINT_ORDER.some((scope) => draft.scopes[scope]) +export function hasSelectedAccessPoint( + draft: AccessControlDraft, + availableAccessPoints: readonly AccessPoint[], +) { + return availableAccessPoints.some((scope) => draft.scopes[scope]) } export function canSaveAccessControl({ draft, baseline, - persistableAccessPoints, + availableAccessPoints, }: { draft: AccessControlDraft baseline?: AccessControlDraft - persistableAccessPoints?: readonly string[] + availableAccessPoints: readonly AccessPoint[] }) { - if (baseline && isAccessControlDraftEqual(draft, baseline)) return false + if (!availableAccessPoints.length) return false + if (baseline && isAccessControlDraftEqual(draft, baseline, availableAccessPoints)) return false if (!draft.enabled) return Boolean(draft.selectedPolicyId) if (!draft.selectedPolicyId) return false - if (persistableAccessPoints) return persistableAccessPoints.length > 0 - return hasSelectedAccessPoint(draft) + return hasSelectedAccessPoint(draft, availableAccessPoints) } diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index a4ed26725baab5..fc5bba54ddcb70 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -1,15 +1,16 @@ 'use client' +import type { ComponentProps } from 'react' import type { AccessControlAssignment } from './chip-status' import type { AccessControlDraft } from './draft' +import type AppIcon from '@/app/components/base/app-icon' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { pricingQueryParamName, pricingQueryParser, @@ -19,8 +20,10 @@ import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' -import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state' -import { deploymentEditionAtom } from '@/features/system-features/state' +import { + canManageNetworkAccessPoliciesAtom, + canReadNetworkAccessAtom, +} from '@/features/network-access/permissions' import { consoleQuery } from '@/service/console' import { AccessControlChipAffix } from './chip-affix' import { getAccessControlChipState } from './chip-status' @@ -31,26 +34,39 @@ import { AccessControlFreePaywall } from './free-paywall' import { accessPointsFromScopes, draftFromBinding, + getAvailableAccessPoints, getNetworkAccessErrorStatus, - scopesFromAccessPoints, } from './network-access' import { AccessControlStatusPanel } from './status-panel' +export type AccessControlAppIcon = Pick< + ComponentProps, + 'iconType' | 'icon' | 'background' | 'imageUrl' +> + +type AccessControlEntryProps = { + appId: string + appIcon: AccessControlAppIcon + canEditBinding: boolean +} + type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void -export function AccessControlEntry() { +export function AccessControlEntry(props: AccessControlEntryProps) { + return +} + +function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlEntryProps) { const { t } = useTranslation() const queryClient = useQueryClient() - const deploymentEdition = useAtomValue(deploymentEditionAtom) - const isManager = useAtomValue(isCurrentWorkspaceManagerAtom) + const canRead = useAtomValue(canReadNetworkAccessAtom) + const canManagePolicies = useAtomValue(canManageNetworkAccessPoliciesAtom) const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) const [_settingsDestination, setSettingsDestination] = useQueryState( settingsQueryParamName, settingsQueryParser, ) - const appInfo = useAppStore((state) => state.appDetail) - const appId = appInfo?.id - const canFetchNetworkAccess = deploymentEdition === 'CLOUD' && isManager + const canFetchNetworkAccess = canRead && Boolean(appId) const groupsQuery = useQuery( consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ enabled: canFetchNetworkAccess, @@ -59,7 +75,7 @@ export function AccessControlEntry() { ) const bindingQuery = useQuery( consoleQuery.apps.byAppId.networkAccessGroup.get.queryOptions({ - input: appId ? { params: { app_id: appId } } : skipToken, + input: { params: { app_id: appId } }, context: { silent: true }, retry: false, enabled: canFetchNetworkAccess && Boolean(appId), @@ -83,44 +99,49 @@ export function AccessControlEntry() { Boolean(appId) && (bindingErrorStatus === 400 || bindingErrorStatus === 403 || bindingErrorStatus === 404) - if (deploymentEdition !== 'CLOUD' || !isManager) return null + if (!canFetchNetworkAccess) return null if (hideForUnsupportedApp) return null - if (groupsQuery.isPending || (appId && bindingQuery.isPending)) return null + if (groupsQuery.isPending || bindingQuery.isPending) return null + if (groupsQuery.isError || bindingQuery.isError || !groupsQuery.data || !bindingQuery.data) + return null + if (!Array.isArray(bindingQuery.data.available_access_points)) return null + const availableAccessPoints = getAvailableAccessPoints(bindingQuery.data.available_access_points) + if (!availableAccessPoints.length) return null const label = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) - const groups = groupsQuery.data?.groups ?? [] - const binding = bindingQuery.data?.binding + const groups = groupsQuery.data.groups + const binding = bindingQuery.data.binding const policies = groups.map((group) => ({ id: group.id, name: group.name, allowed_cidrs: group.allowed_cidrs, })) - const baseline = draftFromBinding(binding) - const resolvedDraft = draft ?? baseline + const baseline = draftFromBinding(binding, availableAccessPoints) + const entitled = bindingQuery.data.entitled + const canMutate = canRead && canEditBinding && entitled + const resolvedDraft = canMutate ? (draft ?? baseline) : baseline const assignment: AccessControlAssignment | null = binding?.group_id ? { policyId: binding.group_id, policyName: groups.find((group) => group.id === binding.group_id)?.name ?? binding.group_id, - scopes: scopesFromAccessPoints(binding.access_points), + scopes: baseline.scopes, enabled: binding.enabled, } : null - const entitled = bindingQuery.data?.entitled ?? groupsQuery.data?.entitled - const canMutate = entitled === true - const dirty = !isAccessControlDraftEqual(resolvedDraft, baseline) + const dirty = !isAccessControlDraftEqual(resolvedDraft, baseline, availableAccessPoints) const persistableAccessPoints = accessPointsFromScopes( resolvedDraft.scopes, - bindingQuery.data?.available_access_points, + availableAccessPoints, ) const canSave = canSaveAccessControl({ draft: resolvedDraft, baseline, - persistableAccessPoints, + availableAccessPoints, }) - const chip = getAccessControlChipState({ entitled, assignment }) + const chip = getAccessControlChipState({ entitled, assignment, availableAccessPoints }) const showPaywall = chip.kind === 'pro' - const showDowngrade = Boolean(assignment) && !canMutate - const showStatus = Boolean(assignment) && canMutate && view === 'status' + const showDowngrade = Boolean(assignment) && !entitled + const showStatus = Boolean(assignment) && entitled && (!canMutate || view === 'status') const tooltip = chip.kind === 'pro' @@ -170,6 +191,7 @@ export function AccessControlEntry() { } const handleCreatePolicy = () => { + if (!canMutate || !canManagePolicies) return setOpen(false) setCreatePolicyOpen(true) } @@ -197,7 +219,7 @@ export function AccessControlEntry() { }, onSuccess?: () => void, ) => { - if (!appId) return + if (!canMutate) return updateBinding.mutate( { @@ -287,17 +309,23 @@ export function AccessControlEntry() { {showPaywall ? ( ) : showDowngrade && assignment ? ( - + ) : showStatus && assignment ? ( { + if (canMutate) setDraft(nextDraft) + }} onSave={handleSave} /> )} @@ -328,13 +361,14 @@ export function AccessControlEntry() { {tooltip} - {createPolicyOpen && ( + {createPolicyOpen && canMutate && canManagePolicies && ( { + if (!canMutate || !canManagePolicies) return createGroup.mutate( { body: { diff --git a/web/app/components/app/access-point/access-control/network-access.ts b/web/app/components/app/access-point/access-control/network-access.ts index ceb7d6f9f091d8..b370ef5077c6d6 100644 --- a/web/app/components/app/access-point/access-control/network-access.ts +++ b/web/app/components/app/access-point/access-control/network-access.ts @@ -65,27 +65,31 @@ export function scopesFromAccessPoints( } } +export function getAvailableAccessPoints( + accessPoints: readonly NetworkAccessPoint[], +): AccessPoint[] { + const available = new Set(accessPoints) + return ACCESS_POINT_ORDER.filter((accessPoint) => available.has(toApiAccessPoint(accessPoint))) +} + export function accessPointsFromScopes( scopes: AccessControlDraft['scopes'], - availableAccessPoints?: readonly string[], + availableAccessPoints: readonly AccessPoint[], ): NetworkAccessPoint[] { - const allowed = availableAccessPoints ? new Set(availableAccessPoints) : null - - return ACCESS_POINT_ORDER.filter((accessPoint) => { - if (!scopes[accessPoint]) return false - if (!allowed) return true - return allowed.has(toApiAccessPoint(accessPoint)) - }).map(toApiAccessPoint) + return availableAccessPoints.filter((accessPoint) => scopes[accessPoint]).map(toApiAccessPoint) } export function draftFromBinding( - binding: AppNetworkAccessGroupBindingResponse | null | undefined, + binding: AppNetworkAccessGroupBindingResponse | null, + availableAccessPoints: readonly AccessPoint[], ): AccessControlDraft { - if (!binding?.group_id) return createDefaultAccessControlDraft() + if (!binding?.group_id) return createDefaultAccessControlDraft(availableAccessPoints) return { selectedPolicyId: binding.group_id, enabled: binding.enabled, - scopes: scopesFromAccessPoints(binding.access_points), + scopes: scopesFromAccessPoints( + accessPointsFromScopes(scopesFromAccessPoints(binding.access_points), availableAccessPoints), + ), } } diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx index e423382fe803b0..56223105522dab 100644 --- a/web/app/components/app/access-point/access-control/policy-field.tsx +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -26,6 +26,8 @@ type AccessControlPolicyFieldProps = { policies: readonly AccessControlPolicy[] selectedPolicyId: string | null currentIp?: string + readOnly?: boolean + canManagePolicies: boolean onCreatePolicy: () => void onManagePolicies: () => void onSelectPolicy: (policyId: string) => void @@ -35,6 +37,8 @@ export function AccessControlPolicyField({ policies, selectedPolicyId, currentIp, + readOnly = false, + canManagePolicies, onCreatePolicy, onManagePolicies, onSelectPolicy, @@ -56,9 +60,15 @@ export function AccessControlPolicyField({

{t(($) => $['studio.accessControl.emptyPoliciesTitle'], { ns: 'deployments' })}

- + {canManagePolicies && !readOnly ? ( + + ) : ( + + )} ) } @@ -123,8 +133,11 @@ export function AccessControlPolicyField({ type="button" role="option" aria-selected={false} + disabled={readOnly} className="flex h-8 w-full cursor-pointer items-center rounded-lg px-2 text-left system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" - onClick={() => onSelectPolicy(policy.id)} + onClick={() => { + if (!readOnly) onSelectPolicy(policy.id) + }} > {policy.name} @@ -155,7 +168,9 @@ export function AccessControlPolicyField({
- $['settings.ipPolicies'], { ns: 'common' })} - onClick={onManagePolicies} - > - - + {manageButton}
{summary && (

diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx index ede80770a31a80..2617fe8fbfc322 100644 --- a/web/app/components/app/access-point/access-control/status-panel.tsx +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -115,7 +115,9 @@ export function AccessControlStatusPanel({ $['studio.accessControl.restrictByIp'], { ns: 'deployments' })} + aria-label={t(($) => $['studio.accessControl.enableAccessControl'], { + ns: 'deployments', + })} onCheckedChange={(next) => { if (readOnly || updating) return if (next) { diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index 61b38166cc6910..c5bf41554886fe 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event' import { createInstance } from 'i18next' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { initReactI18next } from 'react-i18next' +import commonTranslations from '@/i18n/en-US/common.json' +import deploymentTranslations from '@/i18n/en-US/deployments.json' import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' import { createNetworkAccessGroupFixture, @@ -11,47 +13,6 @@ import { import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import IpPoliciesPage from '..' -const translations = vi.hoisted(() => ({ - 'operation.cancel': 'Cancel', - 'operation.close': 'Close', - 'operation.delete': 'Delete', - 'operation.edit': 'Edit', - 'operation.moreActionsFor': 'More actions for {{name}}', - 'operation.save': 'Save', - 'settings.ipPolicies': 'IP Policies', - 'settings.ipPoliciesDescription': - 'Reusable rules that control which IP addresses or ranges can access your apps', - 'settings.ipPolicyAddEntry': 'Add', - 'settings.ipPolicyAllowlist': 'Allowlist', - 'settings.ipPolicyAllowlistHelp': - 'Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.', - 'settings.ipPolicyColumnEnforcing': 'Enforcing', - 'settings.ipPolicyColumnIpEntries': 'IP entries', - 'settings.ipPolicyColumnName': 'Name', - 'settings.ipPolicyColumnUpdatedAt': 'Updated at', - 'settings.ipPolicyCreate': 'Create', - 'settings.ipPolicyEnforcingMany': '{{count}} apps', - 'settings.ipPolicyEnforcingNone': '0 apps', - 'settings.ipPolicyEnforcingOne': '1 app', - 'settings.ipPolicyDeleteBound': 'This policy is in use', - 'settings.ipPolicyDeleteBoundDescription': - 'Delete {{name}}? These apps will allow access from any IP address. This cannot be undone.', - 'settings.ipPolicyDeleteConfirm': 'Delete IP policy?', - 'settings.ipPolicyDeleteDescription': - 'Delete {{name}}? This cannot be undone.', - 'settings.ipPolicyUsedBy': 'Used by {{count}} apps', - 'settings.ipPolicyUsedByLabel': 'Used by', - 'settings.ipPolicyDialogDescription': - 'Specify which IP addresses or ranges can access your apps.', - 'settings.ipPolicyEditTitle': 'Edit IP Policy', - 'settings.ipPolicyName': 'Name', - 'settings.ipPolicyNamePlaceholder': 'e.g. Internal Network', - 'settings.ipPolicyNewTitle': 'New IP Policy', - 'settings.ipPolicyRemoveEntry': 'Remove entry', - 'studio.accessControl.emptyPoliciesTitle': 'No IP policies in this workspace yet', - 'studio.accessControl.policySummaryTwo': 'Allows {{first}} and {{second}}', -})) - vi.unmock('react-i18next') describe('IpPoliciesPage', () => { @@ -65,7 +26,7 @@ describe('IpPoliciesPage', () => { defaultNS: 'common', keySeparator: false, interpolation: { escapeValue: false }, - resources: { 'en-US': { common: translations, deployments: translations } }, + resources: { 'en-US': { common: commonTranslations, deployments: deploymentTranslations } }, }) }) @@ -349,8 +310,8 @@ describe('IpPoliciesPage', () => { expect(screen.getByText('Internal Network')).toBeInTheDocument() expect(screen.getByText('Name')).toBeInTheDocument() - expect(screen.getByText('IP entries')).toBeInTheDocument() - expect(screen.getByText('Enforcing')).toBeInTheDocument() + expect(screen.getByText('IP Entries')).toBeInTheDocument() + expect(screen.getByText('Used by')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument() expect(screen.getByText('0 apps')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) @@ -398,7 +359,10 @@ describe('IpPoliciesPage', () => { await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) await user.click(screen.getByRole('menuitem', { name: 'Edit' })) - expect(screen.getByText('Used by')).toBeInTheDocument() + expect(within(screen.getByRole('dialog')).getByText('Used by')).toBeInTheDocument() + expect( + screen.getByText('Changes take effect immediately wherever this policy is applied.'), + ).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( 'href', '/app/app-a/overview', @@ -445,12 +409,11 @@ describe('IpPoliciesPage', () => { await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) await user.click(screen.getByRole('menuitem', { name: 'Delete' })) - const dialog = screen.getByRole('alertdialog', { name: 'Delete IP policy?' }) + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) expect(dialog).toHaveAccessibleDescription( - /Delete Internal Network\s*\? These apps will allow access from any IP address\. This cannot be undone\./, + 'Apps using this policy will become accessible from any IP address. This can’t be undone.', ) - expect(within(dialog).getByText('Internal Network', { selector: 'strong' })).toBeInTheDocument() - expect(within(dialog).getByText('Used by 2 apps')).toBeInTheDocument() + expect(within(dialog).getByText('Still used by 2 apps')).toBeInTheDocument() expect(globalThis.fetch).not.toHaveBeenCalled() expect(within(dialog).getByRole('button', { name: 'Delete' })).toBeEnabled() expect(within(dialog).getByRole('link', { name: 'Support Bot' })).toHaveAttribute( @@ -474,11 +437,9 @@ describe('IpPoliciesPage', () => { await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) await user.click(screen.getByRole('menuitem', { name: 'Delete' })) - const dialog = screen.getByRole('alertdialog', { name: 'Delete IP policy?' }) - expect(dialog).toHaveAccessibleDescription( - /Delete Internal Network\s*\? This cannot be undone\./, - ) - expect(within(dialog).queryByText(/Used by/)).not.toBeInTheDocument() + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) + expect(dialog).toHaveAccessibleDescription('This can’t be undone.') + expect(within(dialog).queryByText(/Still used by/)).not.toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: button })) await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) @@ -518,7 +479,7 @@ describe('IpPoliciesPage', () => { await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) await user.click(screen.getByRole('menuitem', { name: 'Delete' })) - const dialog = screen.getByRole('alertdialog', { name: 'Delete IP policy?' }) + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }) await user.click(deleteButton) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index 886234f4412ca1..77241b49905e55 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -172,6 +172,9 @@ export function IpPolicyDialog({ {t(($) => $['settings.ipPolicyUsedByLabel'], { ns: 'common' })} +

+ {t(($) => $['settings.ipPolicyEditUsedBy'], { ns: 'common' })} +

)} diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx index 91d4c504e4393d..4c647e51bf93df 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -19,9 +19,10 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMutation } from '@tanstack/react-query' import { useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { consoleQuery } from '@/service/console' import { PolicyReferencedApps } from './referenced-apps' @@ -99,20 +100,29 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps
{canMutate && ( - $['operation.moreActionsFor'], { - ns: 'common', - name: group.name, - })} - className="data-popup-open:bg-state-base-hover" - > - - - } - /> + + $['operation.moreActionsFor'], { + ns: 'common', + name: group.name, + })} + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + } + /> + + {t(($) => $['settings.ipPolicyMore'], { ns: 'common' })} + +
- - {t(($) => $['settings.ipPolicyDeleteConfirm'], { ns: 'common' })} + + {t(($) => $['settings.ipPolicyDeleteConfirm'], { ns: 'common', name: group.name })}
- $['settings.ipPolicyDeleteBoundDescription'] - : ($) => $['settings.ipPolicyDeleteDescription'] - } - values={{ name: group.name }} - components={{ policyName: }} - /> + {isBound + ? t(($) => $['settings.ipPolicyDeleteBoundDescription'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyDeleteDescription'], { ns: 'common' })} {isBound && (
diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index d1447f738d7d9c..231116dd993451 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "هل تريد حذف {{name}}؟ ستسمح هذه التطبيقات بالوصول من أي عنوان IP. لا يمكن التراجع عن هذا الإجراء.", - "settings.ipPolicyDeleteConfirm": "حذف سياسة IP؟", - "settings.ipPolicyDeleteDescription": "هل تريد حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.", + "settings.ipPolicyDeleteBoundDescription": "ستسمح هذه التطبيقات بالوصول من أي عنوان IP. لا يمكن التراجع عن هذا الإجراء.", + "settings.ipPolicyDeleteConfirm": "هل تريد حذف “{{name}}”؟", + "settings.ipPolicyDeleteDescription": "لا يمكن التراجع عن هذا الإجراء.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "تسري التغييرات فورًا في كل مكان تُطبّق فيه هذه السياسة.", "settings.ipPolicyEnforcingMany": "{{count}} تطبيقات", "settings.ipPolicyEnforcingNone": "0 تطبيقات", "settings.ipPolicyEnforcingOne": "تطبيق واحد", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "المزيد", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "لا تزال مستخدمة في {{count}} من التطبيقات", "settings.ipPolicyUsedByLabel": "مستخدم بواسطة", - "settings.ipPolicyUsedBy_one": "يستخدمه {{count}} تطبيق", + "settings.ipPolicyUsedBy_one": "لا تزال مستخدمة في {{count}} من التطبيقات", "settings.members": "الأعضاء", "settings.permissionSet": "مجموعة الأذونات", "settings.permissionSetDescription": "قم بتكوين مجموعات الأذونات لاستخدامها مع التطبيقات وقواعد المعرفة. مجموعة الأذونات هي مجموعة قابلة لإعادة الاستخدام من أذونات عمليات الموارد يمكن تعيينها للأعضاء لموارد محددة.", diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index ad2ff201b6366b..67ec14c0f5c19c 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "إضافة سياسة IP", + "studio.accessControl.description": "تحكّم في الوصول إلى هذا التطبيق بتطبيق سياسة IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "تفعيل التحكم في الوصول", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "يرجى اختيار سياسة IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 76ea7a6f60d340..759b67d71ddbd3 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}} löschen? Diese Apps erlauben dann den Zugriff von jeder IP-Adresse. Dies kann nicht rückgängig gemacht werden.", - "settings.ipPolicyDeleteConfirm": "IP-Richtlinie löschen?", - "settings.ipPolicyDeleteDescription": "{{name}} löschen? Dies kann nicht rückgängig gemacht werden.", + "settings.ipPolicyDeleteBoundDescription": "Diese Apps erlauben dann den Zugriff von jeder IP-Adresse. Dies kann nicht rückgängig gemacht werden.", + "settings.ipPolicyDeleteConfirm": "“{{name}}” löschen?", + "settings.ipPolicyDeleteDescription": "Dies kann nicht rückgängig gemacht werden.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Änderungen werden überall dort sofort wirksam, wo diese Richtlinie angewendet wird.", "settings.ipPolicyEnforcingMany": "{{count}} Apps", "settings.ipPolicyEnforcingNone": "0 Apps", "settings.ipPolicyEnforcingOne": "1 App", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Mehr", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Noch von {{count}} Apps verwendet", "settings.ipPolicyUsedByLabel": "Verwendet von", - "settings.ipPolicyUsedBy_one": "Von {{count}} App verwendet", + "settings.ipPolicyUsedBy_one": "Noch von {{count}} App verwendet", "settings.members": "Mitglieder", "settings.permissionSet": "Berechtigungssatz", "settings.permissionSetDescription": "Konfigurieren Sie Berechtigungssätze zur Verwendung mit Apps und Wissensdatenbanken. Ein Berechtigungssatz ist eine wiederverwendbare Sammlung von Berechtigungen für Ressourcenoperationen, die Mitgliedern für bestimmte Ressourcen zugewiesen werden kann.", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index c5bac7fc530fa5..e4601c34639e0d 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "IP-Richtlinie hinzufügen", + "studio.accessControl.description": "Steuern Sie den Zugriff auf diese App durch Anwenden einer IP-Richtlinie.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Zugriffskontrolle aktivieren", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Bitte wählen Sie eine IP-Richtlinie aus.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 99f72fb6f345c9..09aa5bab7da4ca 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -564,19 +564,19 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Enforcing", - "settings.ipPolicyColumnIpEntries": "IP entries", + "settings.ipPolicyColumnEnforcing": "Used by", + "settings.ipPolicyColumnIpEntries": "IP Entries", "settings.ipPolicyColumnName": "Name", "settings.ipPolicyColumnUpdatedAt": "Updated at", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Delete {{name}}? These apps will allow access from any IP address. This cannot be undone.", - "settings.ipPolicyDeleteConfirm": "Delete IP policy?", - "settings.ipPolicyDeleteDescription": "Delete {{name}}? This cannot be undone.", + "settings.ipPolicyDeleteBoundDescription": "Apps using this policy will become accessible from any IP address. This can’t be undone.", + "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", + "settings.ipPolicyDeleteDescription": "This can’t be undone.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Changes take effect immediately wherever this policy is applied.", "settings.ipPolicyEnforcingMany": "{{count}} apps", "settings.ipPolicyEnforcingNone": "0 apps", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "More", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Still used by {{count}} apps", "settings.ipPolicyUsedByLabel": "Used by", - "settings.ipPolicyUsedBy_one": "Used by {{count}} app", + "settings.ipPolicyUsedBy_one": "Still used by {{count}} app", "settings.members": "Members", "settings.permissionSet": "Permission Set", "settings.permissionSetDescription": "Configure permission sets for use with applications and knowledge bases. A permission set is a reusable collection of resource operation permissions that can be assigned to members for specific resources.", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index c3989be72470f1..62c4453760740d 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -35,15 +35,17 @@ "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unknown", "studio.accessControl.addressCount": "{{count}} addresses", "studio.accessControl.applyTo": "Apply to", - "studio.accessControl.applyToHelp": "Choose which access points to protect.", + "studio.accessControl.applyToHelp": "Choose which access points this policy protects.", "studio.accessControl.applyToHelpSelected": "Choose which access points this policy protects.", "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Add IP Policy", + "studio.accessControl.description": "Control access to this app by applying an IP policy.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Enable access control", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -67,11 +69,12 @@ "studio.accessControl.restrictedTo": "Restricted to {{name}}", "studio.accessControl.saveAnyway": "Save anyway", "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", - "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", - "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectAccessPoint": "Please choose at least one access point.", + "studio.accessControl.selectPolicy": "Select an IP policy", + "studio.accessControl.selectPolicyRequired": "Please select an IP policy.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "All access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 8efc98c6a91d13..99a54d0d05ce18 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "¿Eliminar {{name}}? Estas aplicaciones permitirán el acceso desde cualquier dirección IP. Esta acción no se puede deshacer.", - "settings.ipPolicyDeleteConfirm": "¿Eliminar la política de IP?", - "settings.ipPolicyDeleteDescription": "¿Eliminar {{name}}? Esta acción no se puede deshacer.", + "settings.ipPolicyDeleteBoundDescription": "Estas aplicaciones permitirán el acceso desde cualquier dirección IP. Esta acción no se puede deshacer.", + "settings.ipPolicyDeleteConfirm": "¿Eliminar “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Esta acción no se puede deshacer.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Los cambios se aplican de inmediato donde se utilice esta política.", "settings.ipPolicyEnforcingMany": "{{count}} apps", "settings.ipPolicyEnforcingNone": "0 apps", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Más", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Todavía la usan {{count}} aplicaciones", "settings.ipPolicyUsedByLabel": "Usado por", - "settings.ipPolicyUsedBy_one": "Usada por {{count}} aplicación", + "settings.ipPolicyUsedBy_one": "Todavía la usa {{count}} aplicación", "settings.members": "Miembros", "settings.permissionSet": "Conjunto de permisos", "settings.permissionSetDescription": "Configura conjuntos de permisos para usarlos con aplicaciones y bases de conocimiento. Un conjunto de permisos es una colección reutilizable de permisos de operaciones sobre recursos que se puede asignar a miembros para recursos específicos.", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index 6624fe93722306..8028719fbc8078 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Añadir política de IP", + "studio.accessControl.description": "Controla el acceso a esta aplicación aplicando una política de IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Activar el control de acceso", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Selecciona una política de IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 592198de2a0727..33f03ab1fc9f38 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "آیا {{name}} حذف شود؟ این برنامه‌ها دسترسی از هر نشانی IP را مجاز خواهند کرد. این اقدام قابل بازگشت نیست.", - "settings.ipPolicyDeleteConfirm": "خط‌مشی IP حذف شود؟", - "settings.ipPolicyDeleteDescription": "آیا {{name}} حذف شود؟ این اقدام قابل بازگشت نیست.", + "settings.ipPolicyDeleteBoundDescription": "این برنامه‌ها دسترسی از هر نشانی IP را مجاز خواهند کرد. این اقدام قابل بازگشت نیست.", + "settings.ipPolicyDeleteConfirm": "آیا “{{name}}” حذف شود؟", + "settings.ipPolicyDeleteDescription": "این اقدام قابل بازگشت نیست.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "تغییرات بلافاصله در هر جایی که این خط‌مشی اعمال شده است، اجرا می‌شوند.", "settings.ipPolicyEnforcingMany": "{{count}} برنامه", "settings.ipPolicyEnforcingNone": "۰ برنامه", "settings.ipPolicyEnforcingOne": "۱ برنامه", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "بیشتر", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "هنوز در {{count}} برنامه استفاده می‌شود", "settings.ipPolicyUsedByLabel": "استفاده‌شده توسط", - "settings.ipPolicyUsedBy_one": "استفاده‌شده توسط {{count}} برنامه", + "settings.ipPolicyUsedBy_one": "هنوز در {{count}} برنامه استفاده می‌شود", "settings.members": "اعضا", "settings.permissionSet": "مجموعه مجوزها", "settings.permissionSetDescription": "مجموعه‌های مجوز را برای استفاده با برنامه‌ها و پایگاه‌های دانش پیکربندی کنید. مجموعه مجوز، مجموعه‌ای قابل استفاده مجدد از مجوزهای عملیات منبع است که می‌توان آن را برای منابع مشخص به اعضا اختصاص داد.", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index 04eb777567e70c..eed09640ceb237 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "افزودن خط‌مشی IP", + "studio.accessControl.description": "با اعمال یک خط‌مشی IP، دسترسی به این برنامه را کنترل کنید.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "فعال‌سازی کنترل دسترسی", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "لطفاً یک خط‌مشی IP انتخاب کنید.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index bdc908f5eb2ac5..4af27aa343784d 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Supprimer {{name}} ? Ces applications autoriseront l’accès depuis n’importe quelle adresse IP. Cette action est irréversible.", - "settings.ipPolicyDeleteConfirm": "Supprimer la politique IP ?", - "settings.ipPolicyDeleteDescription": "Supprimer {{name}} ? Cette action est irréversible.", + "settings.ipPolicyDeleteBoundDescription": "Ces applications autoriseront l’accès depuis n’importe quelle adresse IP. Cette action est irréversible.", + "settings.ipPolicyDeleteConfirm": "Supprimer “{{name}}” ?", + "settings.ipPolicyDeleteDescription": "Cette action est irréversible.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Les modifications prennent effet immédiatement partout où cette politique est appliquée.", "settings.ipPolicyEnforcingMany": "{{count}} apps", "settings.ipPolicyEnforcingNone": "0 app", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Plus", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Encore utilisée par {{count}} applications", "settings.ipPolicyUsedByLabel": "Utilisé par", - "settings.ipPolicyUsedBy_one": "Utilisée par {{count}} application", + "settings.ipPolicyUsedBy_one": "Encore utilisée par {{count}} application", "settings.members": "Membres", "settings.permissionSet": "Ensemble d’autorisations", "settings.permissionSetDescription": "Configurez des ensembles d’autorisations à utiliser avec les apps et les bases de connaissances. Un ensemble d’autorisations est une collection réutilisable d’autorisations d’opérations sur les ressources pouvant être attribuée aux membres pour des ressources spécifiques.", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index e857c33fe4b38a..aef3bba279a52e 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Ajouter une politique IP", + "studio.accessControl.description": "Contrôlez l’accès à cette application en appliquant une politique IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Activer le contrôle d’accès", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Veuillez sélectionner une politique IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 1317dc3a2caf17..87007acbd7c364 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}} हटाएं? ये ऐप किसी भी IP पते से एक्सेस की अनुमति देंगे। इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", - "settings.ipPolicyDeleteConfirm": "IP नीति हटाएं?", - "settings.ipPolicyDeleteDescription": "{{name}} हटाएं? इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", + "settings.ipPolicyDeleteBoundDescription": "ये ऐप किसी भी IP पते से एक्सेस की अनुमति देंगे। इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", + "settings.ipPolicyDeleteConfirm": "“{{name}}” हटाएं?", + "settings.ipPolicyDeleteDescription": "इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "यह नीति जहाँ भी लागू है, वहाँ बदलाव तुरंत प्रभावी होंगे।", "settings.ipPolicyEnforcingMany": "{{count}} ऐप", "settings.ipPolicyEnforcingNone": "0 ऐप", "settings.ipPolicyEnforcingOne": "1 ऐप", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "अधिक", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "अभी भी {{count}} ऐप में उपयोग की जा रही है", "settings.ipPolicyUsedByLabel": "इनके द्वारा उपयोग", - "settings.ipPolicyUsedBy_one": "{{count}} ऐप द्वारा उपयोग में", + "settings.ipPolicyUsedBy_one": "अभी भी {{count}} ऐप में उपयोग की जा रही है", "settings.members": "सदस्य", "settings.permissionSet": "अनुमति सेट", "settings.permissionSetDescription": "ऐप्लिकेशन और नॉलेज बेस के साथ उपयोग के लिए अनुमति सेट कॉन्फ़िगर करें। अनुमति सेट संसाधन संचालन अनुमतियों का पुन: उपयोग योग्य संग्रह है, जिसे विशिष्ट संसाधनों के लिए सदस्यों को असाइन किया जा सकता है।", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index abf643a0cf79b1..ca3ebaccc42341 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "IP नीति जोड़ें", + "studio.accessControl.description": "IP नीति लागू करके इस ऐप तक पहुँच नियंत्रित करें।", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "पहुँच नियंत्रण सक्षम करें", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "कृपया एक IP नीति चुनें।", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index f98414a7ddbd80..32bd54689d8578 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Hapus {{name}}? Aplikasi ini akan mengizinkan akses dari alamat IP mana pun. Tindakan ini tidak dapat dibatalkan.", - "settings.ipPolicyDeleteConfirm": "Hapus kebijakan IP?", - "settings.ipPolicyDeleteDescription": "Hapus {{name}}? Tindakan ini tidak dapat dibatalkan.", + "settings.ipPolicyDeleteBoundDescription": "Aplikasi ini akan mengizinkan akses dari alamat IP mana pun. Tindakan ini tidak dapat dibatalkan.", + "settings.ipPolicyDeleteConfirm": "Hapus “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Tindakan ini tidak dapat dibatalkan.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Perubahan langsung berlaku di mana pun kebijakan ini diterapkan.", "settings.ipPolicyEnforcingMany": "{{count}} aplikasi", "settings.ipPolicyEnforcingNone": "0 aplikasi", "settings.ipPolicyEnforcingOne": "1 aplikasi", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Lebih", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Masih digunakan oleh {{count}} aplikasi", "settings.ipPolicyUsedByLabel": "Digunakan oleh", - "settings.ipPolicyUsedBy_one": "Digunakan oleh {{count}} aplikasi", + "settings.ipPolicyUsedBy_one": "Masih digunakan oleh {{count}} aplikasi", "settings.members": "Anggota", "settings.permissionSet": "Kumpulan Izin", "settings.permissionSetDescription": "Konfigurasikan kumpulan izin untuk digunakan dengan aplikasi dan basis pengetahuan. Kumpulan izin adalah koleksi izin operasi sumber daya yang dapat digunakan kembali dan dapat ditetapkan kepada anggota untuk sumber daya tertentu.", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index fe241448b25fd2..173bd42184757b 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Tambah kebijakan IP", + "studio.accessControl.description": "Kontrol akses ke aplikasi ini dengan menerapkan kebijakan IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Aktifkan kontrol akses", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Pilih kebijakan IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index a00507835a54b7..82e023693039a9 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Eliminare {{name}}? Queste app consentiranno l’accesso da qualsiasi indirizzo IP. Questa azione non può essere annullata.", - "settings.ipPolicyDeleteConfirm": "Eliminare la policy IP?", - "settings.ipPolicyDeleteDescription": "Eliminare {{name}}? Questa azione non può essere annullata.", + "settings.ipPolicyDeleteBoundDescription": "Queste app consentiranno l’accesso da qualsiasi indirizzo IP. Questa azione non può essere annullata.", + "settings.ipPolicyDeleteConfirm": "Eliminare “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Questa azione non può essere annullata.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Le modifiche hanno effetto immediato ovunque sia applicata questa policy.", "settings.ipPolicyEnforcingMany": "{{count}} app", "settings.ipPolicyEnforcingNone": "0 app", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Di più", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Ancora utilizzata da {{count}} app", "settings.ipPolicyUsedByLabel": "Usato da", - "settings.ipPolicyUsedBy_one": "Usata da {{count}} app", + "settings.ipPolicyUsedBy_one": "Ancora utilizzata da {{count}} app", "settings.members": "Membri", "settings.permissionSet": "Set di autorizzazioni", "settings.permissionSetDescription": "Configura set di autorizzazioni da usare con app e basi di conoscenza. Un set di autorizzazioni è una raccolta riutilizzabile di autorizzazioni per operazioni sulle risorse che può essere assegnata ai membri per risorse specifiche.", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index f0ae9641e0c124..2642b7c0b8f5c6 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Aggiungi policy IP", + "studio.accessControl.description": "Controlla l’accesso a questa app applicando una policy IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Abilita il controllo degli accessi", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Seleziona una policy IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 9a28f184c3b56f..b144ff25f6aae7 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -564,22 +564,22 @@ "settings.ipPolicyAllowlist": "許可リスト", "settings.ipPolicyAllowlistHelp": "単一のアドレス(203.0.113.42)または CIDR 範囲(10.0.0.0/8)を入力できます。IPv4 と IPv6 の両方に対応しています。", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "適用中", + "settings.ipPolicyColumnEnforcing": "使用中のアプリ", "settings.ipPolicyColumnIpEntries": "IP エントリ", "settings.ipPolicyColumnName": "名前", "settings.ipPolicyColumnUpdatedAt": "更新日時", "settings.ipPolicyCreate": "作成", "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}} を削除しますか?これらのアプリは任意の IP アドレスからアクセスできるようになります。この操作は取り消せません。", - "settings.ipPolicyDeleteConfirm": "IP ポリシーを削除しますか?", - "settings.ipPolicyDeleteDescription": "{{name}} を削除しますか?この操作は取り消せません。", + "settings.ipPolicyDeleteBoundDescription": "このポリシーを使用しているアプリには、すべての IP アドレスからアクセスできるようになります。この操作は元に戻せません。", + "settings.ipPolicyDeleteConfirm": "「{{name}}」を削除しますか?", + "settings.ipPolicyDeleteDescription": "この操作は元に戻せません。", "settings.ipPolicyDialogDescription": "アクセスを許可する IP アドレスまたは範囲を指定します。", "settings.ipPolicyEditTitle": "IP ポリシーを編集", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", - "settings.ipPolicyEnforcingMany": "{{count}} 件のアプリ", - "settings.ipPolicyEnforcingNone": "0 件のアプリ", - "settings.ipPolicyEnforcingOne": "1 件のアプリ", + "settings.ipPolicyEditUsedBy": "変更は、このポリシーが適用されているすべての箇所に即時反映されます。", + "settings.ipPolicyEnforcingMany": "{{count}} 個のアプリ", + "settings.ipPolicyEnforcingNone": "0 個のアプリ", + "settings.ipPolicyEnforcingOne": "1 個のアプリ", "settings.ipPolicyEntryInvalidIpv6": "有効な IPv6 アドレスではありません", "settings.ipPolicyEntryLeadingZeros": "先頭のゼロは使用できません", "settings.ipPolicyEntryMultipleSlashes": "「/」は 1 つだけ使用できます", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "プレフィックス長は数値で指定してください", "settings.ipPolicyEntryPrefixRange": "プレフィックス長は 0〜{{max}} の範囲で指定してください", "settings.ipPolicyEntryUnsupported": "IPv4 / IPv6 アドレスと CIDR 範囲のみサポートしています", + "settings.ipPolicyMore": "その他", "settings.ipPolicyName": "名前", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "例:社内ネットワーク", "settings.ipPolicyNewTitle": "IP ポリシーを作成", "settings.ipPolicyRemoveEntry": "エントリを削除", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", - "settings.ipPolicyUsedByLabel": "使用中", - "settings.ipPolicyUsedBy_one": "{{count}} 個のアプリで使用中", + "settings.ipPolicyUsedBy": "まだ {{count}} 個のアプリで使用中", + "settings.ipPolicyUsedByLabel": "使用中のアプリ", + "settings.ipPolicyUsedBy_one": "まだ {{count}} 個のアプリで使用中", "settings.members": "メンバー", "settings.permissionSet": "権限セット", "settings.permissionSetDescription": "アプリとナレッジベースで使用する権限セットを設定します。権限セットは、特定のリソースに対してメンバーに割り当てられる、リソース操作権限の再利用可能なコレクションです。", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index 6d793a07280eeb..255dc085cfced1 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -35,15 +35,17 @@ "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "不明", "studio.accessControl.addressCount": "{{count}} 件のアドレス", "studio.accessControl.applyTo": "適用先", - "studio.accessControl.applyToHelp": "保護するアクセスポイントを選択します。", + "studio.accessControl.applyToHelp": "このポリシーで保護するアクセスポイントを選択します。", "studio.accessControl.applyToHelpSelected": "このポリシーで保護するアクセスポイントを選択します。", - "studio.accessControl.chipOff": "Off", + "studio.accessControl.chipOff": "オフ", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", - "studio.accessControl.createIpPolicy": "IP ポリシーを作成", + "studio.accessControl.createIpPolicy": "IP ポリシーを追加", + "studio.accessControl.description": "IP ポリシーを適用して、このアプリへのアクセスを制御します。", "studio.accessControl.downgradeDescription": "アクセス制御には Pro プランが必要です。", "studio.accessControl.downgradeTitle": "このアプリは保護されていません", "studio.accessControl.emptyPoliciesTitle": "このワークスペースにはまだ IP ポリシーがありません", + "studio.accessControl.enableAccessControl": "アクセス制御を有効にする", "studio.accessControl.entryLabel": "アクセス制御", "studio.accessControl.excluded": "対象外", "studio.accessControl.ipPolicy": "IP ポリシー", @@ -67,11 +69,12 @@ "studio.accessControl.restrictedTo": "{{name}} に制限", "studio.accessControl.saveAnyway": "保存する", "studio.accessControl.saveWithoutOwnIp": "自分の IP を含めずに保存しますか?", - "studio.accessControl.selectAccessPoint": "保護するアクセスポイントを少なくとも 1 つ選択してください。", - "studio.accessControl.selectPolicy": "ポリシーを選択", + "studio.accessControl.selectAccessPoint": "少なくとも 1 つのアクセスポイントを選択してください。", + "studio.accessControl.selectPolicy": "IP ポリシーを選択", + "studio.accessControl.selectPolicyRequired": "IP ポリシーを選択してください。", "studio.accessControl.tooltipOff": "未設定", - "studio.accessControl.tooltipOn": "{{name}}、すべてのアクセスポイント", - "studio.accessControl.tooltipPartial": "{{name}}、{{n}} / {{m}} のアクセスポイント", + "studio.accessControl.tooltipOn": "すべてのアクセスポイント", + "studio.accessControl.tooltipPartial": "アクセスポイント {{total}} 個中 {{n}} 個", "studio.accessControl.tooltipPaused": "一時停止 — {{name}} は設定済みですが適用されていません", "studio.accessControl.tooltipPro": "アクセス制御には Pro プランが必要です", "studio.accessControl.turnOffConfirm": "オフにする", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 3048cd38ae072e..543a0d863f5d53 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}}을(를) 삭제하시겠습니까? 이 앱들은 모든 IP 주소에서 접근할 수 있게 됩니다. 이 작업은 되돌릴 수 없습니다.", - "settings.ipPolicyDeleteConfirm": "IP 정책을 삭제하시겠습니까?", - "settings.ipPolicyDeleteDescription": "{{name}}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "settings.ipPolicyDeleteBoundDescription": "이 앱들은 모든 IP 주소에서 접근할 수 있게 됩니다. 이 작업은 되돌릴 수 없습니다.", + "settings.ipPolicyDeleteConfirm": "“{{name}}”을(를) 삭제하시겠습니까?", + "settings.ipPolicyDeleteDescription": "이 작업은 되돌릴 수 없습니다.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "이 정책이 적용된 모든 곳에 변경 사항이 즉시 반영됩니다.", "settings.ipPolicyEnforcingMany": "앱 {{count}}개", "settings.ipPolicyEnforcingNone": "앱 0개", "settings.ipPolicyEnforcingOne": "앱 1개", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "더 많은", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "아직 {{count}}개의 앱에서 사용 중", "settings.ipPolicyUsedByLabel": "사용 중", - "settings.ipPolicyUsedBy_one": "{{count}}개 앱에서 사용 중", + "settings.ipPolicyUsedBy_one": "아직 {{count}}개의 앱에서 사용 중", "settings.members": "멤버", "settings.permissionSet": "권한 세트", "settings.permissionSetDescription": "앱과 지식 베이스에서 사용할 권한 세트를 구성합니다. 권한 세트는 특정 리소스에 대해 멤버에게 할당할 수 있는 재사용 가능한 리소스 작업 권한 모음입니다.", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index b9a6f539ffaafe..6b6a006facf970 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "IP 정책 추가", + "studio.accessControl.description": "IP 정책을 적용하여 이 앱에 대한 접근을 제어합니다.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "접근 제어 활성화", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "IP 정책을 선택하세요.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index d5ab2d84dcdf33..e9e78ff4372bb2 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "ລຶບ {{name}} ຫຼືບໍ່? ແອັບເຫຼົ່ານີ້ຈະອະນຸຍາດໃຫ້ເຂົ້າເຖິງຈາກທີ່ຢູ່ IP ໃດກໍໄດ້. ການດຳເນີນການນີ້ບໍ່ສາມາດຍົກເລີກໄດ້.", - "settings.ipPolicyDeleteConfirm": "ລຶບນະໂຍບາຍ IP ຫຼືບໍ່?", - "settings.ipPolicyDeleteDescription": "ລຶບ {{name}} ຫຼືບໍ່? ການດຳເນີນການນີ້ບໍ່ສາມາດຍົກເລີກໄດ້.", + "settings.ipPolicyDeleteBoundDescription": "ແອັບເຫຼົ່ານີ້ຈະອະນຸຍາດໃຫ້ເຂົ້າເຖິງຈາກທີ່ຢູ່ IP ໃດກໍໄດ້. ການດຳເນີນການນີ້ບໍ່ສາມາດຍົກເລີກໄດ້.", + "settings.ipPolicyDeleteConfirm": "ລຶບ “{{name}}” ຫຼືບໍ່?", + "settings.ipPolicyDeleteDescription": "ການດຳເນີນການນີ້ບໍ່ສາມາດຍົກເລີກໄດ້.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "ການປ່ຽນແປງຈະມີຜົນທັນທີໃນທຸກບ່ອນທີ່ນຳໃຊ້ນະໂຍບາຍນີ້.", "settings.ipPolicyEnforcingMany": "{{count}} ແອັບ", "settings.ipPolicyEnforcingNone": "0 ແອັບ", "settings.ipPolicyEnforcingOne": "1 ແອັບ", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "ເພີ່ມເຕີມ", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "ຍັງມີ {{count}} ແອັບທີ່ໃຊ້ຢູ່", "settings.ipPolicyUsedByLabel": "ຖືກໃຊ້ໂດຍ", - "settings.ipPolicyUsedBy_one": "ຖືກໃຊ້ໂດຍ {{count}} ແອັບ", + "settings.ipPolicyUsedBy_one": "ຍັງມີ {{count}} ແອັບທີ່ໃຊ້ຢູ່", "settings.members": "ສະມາຊິກ", "settings.permissionSet": "ຊຸດການອະນຸຍາດ", "settings.permissionSetDescription": "ຕັ້ງຄ່າຊຸດການອະນຸຍາດສຳລັບໃຊ້ກັບແອັບ ແລະ ຖານຄວາມຮູ້. ຊຸດການອະນຸຍາດແມ່ນການລວມເອົາສິດການດຳເນີນການຊັບພະຍາກອນທີ່ສາມາດນຳມາໃຊ້ຄືນໄດ້ ເຊິ່ງສາມາດມອບໝາຍໃຫ້ສະມາຊິກສຳລັບຊັບພະຍາກອນສະເພາະ.", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index def724d64cd88a..7f24b956f1a054 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "ເພີ່ມນະໂຍບາຍ IP", + "studio.accessControl.description": "ຄວບຄຸມການເຂົ້າເຖິງແອັບນີ້ໂດຍນຳໃຊ້ນະໂຍບາຍ IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "ເປີດໃຊ້ການຄວບຄຸມການເຂົ້າເຖິງ", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "ກະລຸນາເລືອກນະໂຍບາຍ IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 2c98a328a46197..3c18334f979ffa 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}} verwijderen? Deze apps staan dan toegang vanaf elk IP-adres toe. Dit kan niet ongedaan worden gemaakt.", - "settings.ipPolicyDeleteConfirm": "IP-beleid verwijderen?", - "settings.ipPolicyDeleteDescription": "{{name}} verwijderen? Dit kan niet ongedaan worden gemaakt.", + "settings.ipPolicyDeleteBoundDescription": "Deze apps staan dan toegang vanaf elk IP-adres toe. Dit kan niet ongedaan worden gemaakt.", + "settings.ipPolicyDeleteConfirm": "“{{name}}” verwijderen?", + "settings.ipPolicyDeleteDescription": "Dit kan niet ongedaan worden gemaakt.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Wijzigingen worden onmiddellijk van kracht overal waar dit beleid wordt toegepast.", "settings.ipPolicyEnforcingMany": "{{count}} apps", "settings.ipPolicyEnforcingNone": "0 apps", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "More", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Nog in gebruik door {{count}} apps", "settings.ipPolicyUsedByLabel": "Gebruikt door", - "settings.ipPolicyUsedBy_one": "Gebruikt door {{count}} app", + "settings.ipPolicyUsedBy_one": "Nog in gebruik door {{count}} apps", "settings.members": "Members", "settings.permissionSet": "Set met rechten", "settings.permissionSetDescription": "Configureer sets met rechten voor gebruik met apps en kennisbanken. Een set met rechten is een herbruikbare verzameling rechten voor resourcebewerkingen die aan leden kan worden toegewezen voor specifieke resources.", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index ddbc5c0585d974..6683e74c44f43a 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "IP-beleid toevoegen", + "studio.accessControl.description": "Beheer de toegang tot deze app door een IP-beleid toe te passen.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Toegangsbeheer inschakelen", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Selecteer een IP-beleid.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 988fe693672a9d..e0fb110a0664e0 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Usunąć {{name}}? Te aplikacje będą zezwalać na dostęp z dowolnego adresu IP. Tej operacji nie można cofnąć.", - "settings.ipPolicyDeleteConfirm": "Usunąć politykę IP?", - "settings.ipPolicyDeleteDescription": "Usunąć {{name}}? Tej operacji nie można cofnąć.", + "settings.ipPolicyDeleteBoundDescription": "Te aplikacje będą zezwalać na dostęp z dowolnego adresu IP. Tej operacji nie można cofnąć.", + "settings.ipPolicyDeleteConfirm": "Usunąć “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Tej operacji nie można cofnąć.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Zmiany obowiązują natychmiast wszędzie tam, gdzie stosowana jest ta polityka.", "settings.ipPolicyEnforcingMany": "{{count}} aplikacji", "settings.ipPolicyEnforcingNone": "0 aplikacji", "settings.ipPolicyEnforcingOne": "1 aplikacja", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Więcej", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Liczba aplikacji nadal korzystających z tej polityki: {{count}}", "settings.ipPolicyUsedByLabel": "Używane przez", - "settings.ipPolicyUsedBy_one": "Używana przez {{count}} aplikację", + "settings.ipPolicyUsedBy_one": "Liczba aplikacji nadal korzystających z tej polityki: {{count}}", "settings.members": "Członkowie", "settings.permissionSet": "Zestaw uprawnień", "settings.permissionSetDescription": "Skonfiguruj zestawy uprawnień do użycia z aplikacjami i bazami wiedzy. Zestaw uprawnień to wielokrotnego użytku zbiór uprawnień do operacji na zasobach, który można przypisywać członkom dla konkretnych zasobów.", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index 23c3978a1d9389..e77af8370887c0 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Dodaj politykę IP", + "studio.accessControl.description": "Kontroluj dostęp do tej aplikacji, stosując politykę IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Włącz kontrolę dostępu", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Wybierz politykę IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index ee9613cc6a115e..dc04f0e7886ce0 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Excluir {{name}}? Esses aplicativos permitirão o acesso de qualquer endereço IP. Esta ação não pode ser desfeita.", - "settings.ipPolicyDeleteConfirm": "Excluir política de IP?", - "settings.ipPolicyDeleteDescription": "Excluir {{name}}? Esta ação não pode ser desfeita.", + "settings.ipPolicyDeleteBoundDescription": "Esses aplicativos permitirão o acesso de qualquer endereço IP. Esta ação não pode ser desfeita.", + "settings.ipPolicyDeleteConfirm": "Excluir “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Esta ação não pode ser desfeita.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "As alterações entram em vigor imediatamente onde esta política estiver aplicada.", "settings.ipPolicyEnforcingMany": "{{count}} apps", "settings.ipPolicyEnforcingNone": "0 apps", "settings.ipPolicyEnforcingOne": "1 app", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Mais", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Ainda usada por {{count}} aplicativos", "settings.ipPolicyUsedByLabel": "Usado por", - "settings.ipPolicyUsedBy_one": "Usada por {{count}} aplicativo", + "settings.ipPolicyUsedBy_one": "Ainda usada por {{count}} aplicativo", "settings.members": "Membros", "settings.permissionSet": "Conjunto de permissões", "settings.permissionSetDescription": "Configure conjuntos de permissões para uso com apps e bases de conhecimento. Um conjunto de permissões é uma coleção reutilizável de permissões de operações de recursos que pode ser atribuída a membros para recursos específicos.", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index a0e62c5dd94841..1ef90629eed226 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Adicionar política de IP", + "studio.accessControl.description": "Controle o acesso a este aplicativo aplicando uma política de IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Ativar controle de acesso", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Selecione uma política de IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 2cef8176e76648..abaefcb955cf76 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Ștergeți {{name}}? Aceste aplicații vor permite accesul de la orice adresă IP. Această acțiune nu poate fi anulată.", - "settings.ipPolicyDeleteConfirm": "Ștergeți politica IP?", - "settings.ipPolicyDeleteDescription": "Ștergeți {{name}}? Această acțiune nu poate fi anulată.", + "settings.ipPolicyDeleteBoundDescription": "Aceste aplicații vor permite accesul de la orice adresă IP. Această acțiune nu poate fi anulată.", + "settings.ipPolicyDeleteConfirm": "Ștergeți “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Această acțiune nu poate fi anulată.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Modificările se aplică imediat oriunde este utilizată această politică.", "settings.ipPolicyEnforcingMany": "{{count}} aplicații", "settings.ipPolicyEnforcingNone": "0 aplicații", "settings.ipPolicyEnforcingOne": "1 aplicație", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Mai mult", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Numărul de aplicații care încă folosesc politica: {{count}}", "settings.ipPolicyUsedByLabel": "Folosit de", - "settings.ipPolicyUsedBy_one": "Utilizată de {{count}} aplicație", + "settings.ipPolicyUsedBy_one": "Numărul de aplicații care încă folosesc politica: {{count}}", "settings.members": "Membri", "settings.permissionSet": "Set de permisiuni", "settings.permissionSetDescription": "Configurați seturi de permisiuni pentru utilizare cu aplicații și baze de cunoștințe. Un set de permisiuni este o colecție reutilizabilă de permisiuni pentru operațiuni asupra resurselor, care poate fi atribuită membrilor pentru resurse specifice.", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index 1932cc6d2bca85..6bacd33a8f756c 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Adăugați o politică IP", + "studio.accessControl.description": "Controlați accesul la această aplicație aplicând o politică IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Activați controlul accesului", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Selectați o politică IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 8bea0b21adce0a..d8d58135d0b9ca 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Удалить {{name}}? Эти приложения станут доступны с любого IP-адреса. Это действие нельзя отменить.", - "settings.ipPolicyDeleteConfirm": "Удалить IP-политику?", - "settings.ipPolicyDeleteDescription": "Удалить {{name}}? Это действие нельзя отменить.", + "settings.ipPolicyDeleteBoundDescription": "Эти приложения станут доступны с любого IP-адреса. Это действие нельзя отменить.", + "settings.ipPolicyDeleteConfirm": "Удалить “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Это действие нельзя отменить.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Изменения вступают в силу немедленно везде, где применяется эта политика.", "settings.ipPolicyEnforcingMany": "{{count}} приложений", "settings.ipPolicyEnforcingNone": "0 приложений", "settings.ipPolicyEnforcingOne": "1 приложение", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Больше", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Количество приложений, всё ещё использующих политику: {{count}}", "settings.ipPolicyUsedByLabel": "Используется", - "settings.ipPolicyUsedBy_one": "Используется {{count}} приложением", + "settings.ipPolicyUsedBy_one": "Количество приложений, всё ещё использующих политику: {{count}}", "settings.members": "Участники", "settings.permissionSet": "Набор разрешений", "settings.permissionSetDescription": "Настройте наборы разрешений для использования с приложениями и базами знаний. Набор разрешений — это многократно используемая коллекция разрешений на операции с ресурсами, которую можно назначать участникам для конкретных ресурсов.", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index 5e874c30d12b90..530a6c28c12611 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Добавить IP-политику", + "studio.accessControl.description": "Управляйте доступом к этому приложению с помощью IP-политики.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Включить контроль доступа", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Выберите IP-политику.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 810d24437fdbc8..e134c85acfacf0 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Izbrišem {{name}}? Te aplikacije bodo dovolile dostop s katerega koli naslova IP. Tega dejanja ni mogoče razveljaviti.", - "settings.ipPolicyDeleteConfirm": "Izbrišem pravilnik IP?", - "settings.ipPolicyDeleteDescription": "Izbrišem {{name}}? Tega dejanja ni mogoče razveljaviti.", + "settings.ipPolicyDeleteBoundDescription": "Te aplikacije bodo dovolile dostop s katerega koli naslova IP. Tega dejanja ni mogoče razveljaviti.", + "settings.ipPolicyDeleteConfirm": "Izbrišem “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Tega dejanja ni mogoče razveljaviti.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Spremembe začnejo veljati takoj povsod, kjer se uporablja ta pravilnik.", "settings.ipPolicyEnforcingMany": "{{count}} aplikacij", "settings.ipPolicyEnforcingNone": "0 aplikacij", "settings.ipPolicyEnforcingOne": "1 aplikacija", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Več", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Število aplikacij, ki še uporabljajo pravilnik: {{count}}", "settings.ipPolicyUsedByLabel": "Uporablja", - "settings.ipPolicyUsedBy_one": "Uporablja jo {{count}} aplikacija", + "settings.ipPolicyUsedBy_one": "Število aplikacij, ki še uporabljajo pravilnik: {{count}}", "settings.members": "Člani", "settings.permissionSet": "Nabor dovoljenj", "settings.permissionSetDescription": "Konfigurirajte nabore dovoljenj za uporabo z aplikacijami in bazami znanja. Nabor dovoljenj je ponovno uporabna zbirka dovoljenj za operacije nad viri, ki jo je mogoče dodeliti članom za določene vire.", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 11386219a976b9..10a367955d0282 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Dodaj pravilnik IP", + "studio.accessControl.description": "Nadzirajte dostop do te aplikacije z uporabo pravilnika IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Omogoči nadzor dostopa", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Izberite pravilnik IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 89942ee87b9a68..95ad2134ef7f05 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "ลบ {{name}} หรือไม่? แอปเหล่านี้จะอนุญาตให้เข้าถึงจากที่อยู่ IP ใดก็ได้ การดำเนินการนี้ไม่สามารถยกเลิกได้", - "settings.ipPolicyDeleteConfirm": "ลบนโยบาย IP หรือไม่?", - "settings.ipPolicyDeleteDescription": "ลบ {{name}} หรือไม่? การดำเนินการนี้ไม่สามารถยกเลิกได้", + "settings.ipPolicyDeleteBoundDescription": "แอปเหล่านี้จะอนุญาตให้เข้าถึงจากที่อยู่ IP ใดก็ได้ การดำเนินการนี้ไม่สามารถยกเลิกได้", + "settings.ipPolicyDeleteConfirm": "ลบ “{{name}}” หรือไม่?", + "settings.ipPolicyDeleteDescription": "การดำเนินการนี้ไม่สามารถยกเลิกได้", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "การเปลี่ยนแปลงจะมีผลทันทีในทุกที่ที่ใช้นโยบายนี้", "settings.ipPolicyEnforcingMany": "{{count}} แอป", "settings.ipPolicyEnforcingNone": "0 แอป", "settings.ipPolicyEnforcingOne": "1 แอป", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "มากขึ้น", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "ยังมี {{count}} แอปที่ใช้นโยบายนี้อยู่", "settings.ipPolicyUsedByLabel": "ถูกใช้โดย", - "settings.ipPolicyUsedBy_one": "ใช้งานโดย {{count}} แอป", + "settings.ipPolicyUsedBy_one": "ยังมี {{count}} แอปที่ใช้นโยบายนี้อยู่", "settings.members": "สมาชิก", "settings.permissionSet": "ชุดสิทธิ์", "settings.permissionSetDescription": "กำหนดค่าชุดสิทธิ์สำหรับใช้กับแอปและฐานความรู้ ชุดสิทธิ์คือชุดสิทธิ์การดำเนินการกับทรัพยากรที่นำกลับมาใช้ซ้ำได้ และสามารถกำหนดให้สมาชิกสำหรับทรัพยากรเฉพาะได้", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index fe8296fda02d81..12d57095cf8c93 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "เพิ่มนโยบาย IP", + "studio.accessControl.description": "ควบคุมการเข้าถึงแอปนี้โดยใช้นโยบาย IP", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "เปิดใช้งานการควบคุมการเข้าถึง", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "โปรดเลือกนโยบาย IP", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 76b9096f66011f..54522a58c36b4d 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "{{name}} silinsin mi? Bu uygulamalar herhangi bir IP adresinden erişime izin verecek. Bu işlem geri alınamaz.", - "settings.ipPolicyDeleteConfirm": "IP politikası silinsin mi?", - "settings.ipPolicyDeleteDescription": "{{name}} silinsin mi? Bu işlem geri alınamaz.", + "settings.ipPolicyDeleteBoundDescription": "Bu uygulamalar herhangi bir IP adresinden erişime izin verecek. Bu işlem geri alınamaz.", + "settings.ipPolicyDeleteConfirm": "“{{name}}” silinsin mi?", + "settings.ipPolicyDeleteDescription": "Bu işlem geri alınamaz.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Değişiklikler bu politikanın uygulandığı her yerde hemen geçerli olur.", "settings.ipPolicyEnforcingMany": "{{count}} uygulama", "settings.ipPolicyEnforcingNone": "0 uygulama", "settings.ipPolicyEnforcingOne": "1 uygulama", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Daha fazla", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Hâlâ {{count}} uygulama tarafından kullanılıyor", "settings.ipPolicyUsedByLabel": "Kullanan", - "settings.ipPolicyUsedBy_one": "{{count}} uygulama tarafından kullanılıyor", + "settings.ipPolicyUsedBy_one": "Hâlâ {{count}} uygulama tarafından kullanılıyor", "settings.members": "Üyeler", "settings.permissionSet": "İzin Seti", "settings.permissionSetDescription": "Uygulamalar ve bilgi tabanlarıyla kullanılacak izin setlerini yapılandırın. İzin seti, belirli kaynaklar için üyelere atanabilen yeniden kullanılabilir kaynak işlem izinleri koleksiyonudur.", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index e45ac100f34c4b..c7aa51ebc2f0f1 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "IP politikası ekle", + "studio.accessControl.description": "Bir IP politikası uygulayarak bu uygulamaya erişimi denetleyin.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Erişim denetimini etkinleştir", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Lütfen bir IP politikası seçin.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index b13ad3f6455379..1e1babdfae7727 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Видалити {{name}}? Ці застосунки дозволятимуть доступ із будь-якої IP-адреси. Цю дію неможливо скасувати.", - "settings.ipPolicyDeleteConfirm": "Видалити IP-політику?", - "settings.ipPolicyDeleteDescription": "Видалити {{name}}? Цю дію неможливо скасувати.", + "settings.ipPolicyDeleteBoundDescription": "Ці застосунки дозволятимуть доступ із будь-якої IP-адреси. Цю дію неможливо скасувати.", + "settings.ipPolicyDeleteConfirm": "Видалити “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Цю дію неможливо скасувати.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Зміни набувають чинності негайно всюди, де застосовується ця політика.", "settings.ipPolicyEnforcingMany": "{{count}} застосунків", "settings.ipPolicyEnforcingNone": "0 застосунків", "settings.ipPolicyEnforcingOne": "1 застосунок", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Більше", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Кількість застосунків, які досі використовують політику: {{count}}", "settings.ipPolicyUsedByLabel": "Використовується", - "settings.ipPolicyUsedBy_one": "Використовується {{count}} застосунком", + "settings.ipPolicyUsedBy_one": "Кількість застосунків, які досі використовують політику: {{count}}", "settings.members": "Учасники", "settings.permissionSet": "Набір дозволів", "settings.permissionSetDescription": "Налаштуйте набори дозволів для використання із застосунками та базами знань. Набір дозволів — це багаторазова колекція дозволів на операції з ресурсами, яку можна призначати учасникам для конкретних ресурсів.", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index 46fb5339c61182..5e27f75f55ebd0 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Додати IP-політику", + "studio.accessControl.description": "Керуйте доступом до цього застосунку за допомогою IP-політики.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Увімкнути контроль доступу", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Виберіть IP-політику.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index b757865f7d5ebe..b325400c353dfc 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", - "settings.ipPolicyDeleteBoundDescription": "Xóa {{name}}? Các ứng dụng này sẽ cho phép truy cập từ mọi địa chỉ IP. Không thể hoàn tác thao tác này.", - "settings.ipPolicyDeleteConfirm": "Xóa chính sách IP?", - "settings.ipPolicyDeleteDescription": "Xóa {{name}}? Không thể hoàn tác thao tác này.", + "settings.ipPolicyDeleteBoundDescription": "Các ứng dụng này sẽ cho phép truy cập từ mọi địa chỉ IP. Không thể hoàn tác thao tác này.", + "settings.ipPolicyDeleteConfirm": "Xóa “{{name}}”?", + "settings.ipPolicyDeleteDescription": "Không thể hoàn tác thao tác này.", "settings.ipPolicyDialogDescription": "Specify which IP addresses or ranges can access your apps.", "settings.ipPolicyEditTitle": "Edit IP Policy", - "settings.ipPolicyEditUsedBy": "This policy is used by {{count}} apps. Saving updates the allowlist for all of them.", + "settings.ipPolicyEditUsedBy": "Các thay đổi có hiệu lực ngay lập tức ở mọi nơi áp dụng chính sách này.", "settings.ipPolicyEnforcingMany": "{{count}} ứng dụng", "settings.ipPolicyEnforcingNone": "0 ứng dụng", "settings.ipPolicyEnforcingOne": "1 ứng dụng", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "Prefix length must be a number", "settings.ipPolicyEntryPrefixRange": "Prefix length must be 0–{{max}}", "settings.ipPolicyEntryUnsupported": "Only IPv4 / IPv6 addresses and CIDR ranges are supported", + "settings.ipPolicyMore": "Hơn", "settings.ipPolicyName": "Name", "settings.ipPolicyNameMax": "Name must be {{max}} characters or fewer", "settings.ipPolicyNamePlaceholder": "e.g. Internal Network", "settings.ipPolicyNewTitle": "New IP Policy", "settings.ipPolicyRemoveEntry": "Remove entry", - "settings.ipPolicyUsedBy": "Used by {{count}} apps", + "settings.ipPolicyUsedBy": "Vẫn được {{count}} ứng dụng sử dụng", "settings.ipPolicyUsedByLabel": "Được dùng bởi", - "settings.ipPolicyUsedBy_one": "Được {{count}} ứng dụng sử dụng", + "settings.ipPolicyUsedBy_one": "Vẫn được {{count}} ứng dụng sử dụng", "settings.members": "Thành viên", "settings.permissionSet": "Bộ quyền", "settings.permissionSetDescription": "Định cấu hình các bộ quyền để sử dụng với ứng dụng và cơ sở kiến thức. Bộ quyền là tập hợp có thể tái sử dụng các quyền thao tác trên tài nguyên, có thể được gán cho thành viên đối với các tài nguyên cụ thể.", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index 73fe36b584073d..8ee219a94ce087 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} of {{m}}", - "studio.accessControl.createIpPolicy": "Create an IP policy", + "studio.accessControl.createIpPolicy": "Thêm chính sách IP", + "studio.accessControl.description": "Kiểm soát quyền truy cập ứng dụng này bằng cách áp dụng chính sách IP.", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "No IP policies in this workspace yet", + "studio.accessControl.enableAccessControl": "Bật kiểm soát truy cập", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "Excluded", "studio.accessControl.ipPolicy": "IP Policy", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "Save without your own IP?", "studio.accessControl.selectAccessPoint": "Select at least one access point to protect.", "studio.accessControl.selectPolicy": "Select a policy", + "studio.accessControl.selectPolicyRequired": "Vui lòng chọn chính sách IP.", "studio.accessControl.tooltipOff": "Not set up", - "studio.accessControl.tooltipOn": "{{name}}, all access points", - "studio.accessControl.tooltipPartial": "{{name}}, {{n}} of {{m}} access points", + "studio.accessControl.tooltipOn": "all access points", + "studio.accessControl.tooltipPartial": "{{n}} of {{total}} access points", "studio.accessControl.tooltipPaused": "Paused — {{name}} is configured but not enforcing", "studio.accessControl.tooltipPro": "Access control requires the Pro plan", "studio.accessControl.turnOffConfirm": "Turn off", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index 094c924d878d4d..660f5fe6a19ab2 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.inService": "服务中", "agentDetail.access.status.outOfService": "已停用", "agentDetail.access.status.unavailable": "不可用", - "agentDetail.access.title": "访问点", + "agentDetail.access.title": "访问入口", "agentDetail.access.toggleSurface": "切换 {{name}} 访问状态", "agentDetail.access.webApp.accessUrl": "访问 URL", "agentDetail.access.webApp.actions.customFrontend": "自定义前端", @@ -311,7 +311,7 @@ "agentDetail.metadata.sourceLabel": "来源", "agentDetail.monitoring.change": "较上一周期 {{value}}", "agentDetail.monitoring.dateRangeLabel": "日期范围", - "agentDetail.monitoring.description": "跟踪该 Agent 在所有访问点上的活动、成本与交互质量。", + "agentDetail.monitoring.description": "跟踪该 Agent 在所有访问入口上的活动、成本与交互质量。", "agentDetail.monitoring.learnMore": "了解更多", "agentDetail.monitoring.loadFailed": "监控数据加载失败", "agentDetail.monitoring.metrics.activeUsers.explanation": "与该 Agent 交互过的独立终端用户数。", @@ -342,7 +342,7 @@ "agentDetail.monitoring.units.tokenPerSecond": "Token/秒", "agentDetail.navigationLabel": "Agent 导航", "agentDetail.publish": "发布", - "agentDetail.sections.access": "访问点", + "agentDetail.sections.access": "访问入口", "agentDetail.sections.access-config": "访问权限", "agentDetail.sections.configure": "配置", "agentDetail.sections.logs": "日志", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index f6a34ea7c1dae9..e04fb4873ac932 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -96,7 +96,7 @@ "apiBasedExtension.title": "Custom Endpoints 提供集中式 API 管理,简化配置,方便在 Dify 的应用中使用。", "apiBasedExtension.type": "类型", "apiBasedExtensionPage.description": "通过 API 扩展 Dify 的模块能力——为应用接入自定义内容审核或外部数据工具。", - "appMenus.accessPoint": "访问点", + "appMenus.accessPoint": "访问入口", "appMenus.annotations": "标注", "appMenus.apiAccess": "访问 API", "appMenus.apiAccessTip": "此知识库可通过服务 API 访问", @@ -564,22 +564,22 @@ "settings.ipPolicyAllowlist": "允许列表", "settings.ipPolicyAllowlistHelp": "支持单个地址(203.0.113.42)或 CIDR 网段(10.0.0.0/8),IPv4 与 IPv6 均可。", "settings.ipPolicyCidrMax": "一条策略最多包含 {{max}} 个地址", - "settings.ipPolicyColumnEnforcing": "生效中", + "settings.ipPolicyColumnEnforcing": "生效应用", "settings.ipPolicyColumnIpEntries": "IP 条目", "settings.ipPolicyColumnName": "名称", "settings.ipPolicyColumnUpdatedAt": "更新时间", "settings.ipPolicyCreate": "创建", "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "该策略正在使用中", - "settings.ipPolicyDeleteBoundDescription": "确定删除 {{name}}?这些应用将允许从任意 IP 地址访问。此操作无法撤销。", - "settings.ipPolicyDeleteConfirm": "删除 IP 策略?", - "settings.ipPolicyDeleteDescription": "确定删除 {{name}}?此操作无法撤销。", + "settings.ipPolicyDeleteBoundDescription": "正在引用此策略的应用将完全开放访问。此操作无法撤销。", + "settings.ipPolicyDeleteConfirm": "删除“{{name}}”?", + "settings.ipPolicyDeleteDescription": "此操作无法撤销。", "settings.ipPolicyDialogDescription": "指定允许访问的 IP 地址或网段。", "settings.ipPolicyEditTitle": "编辑 IP 策略", - "settings.ipPolicyEditUsedBy": "该策略已被 {{count}} 个应用使用。保存后会更新所有这些应用的允许列表。", - "settings.ipPolicyEnforcingMany": "{{count}} 个应用", - "settings.ipPolicyEnforcingNone": "0 个应用", - "settings.ipPolicyEnforcingOne": "1 个应用", + "settings.ipPolicyEditUsedBy": "修改将立即对所有引用此策略的应用生效。", + "settings.ipPolicyEnforcingMany": "{{count}} 个", + "settings.ipPolicyEnforcingNone": "0 个", + "settings.ipPolicyEnforcingOne": "1 个", "settings.ipPolicyEntryInvalidIpv6": "不是有效的 IPv6 地址", "settings.ipPolicyEntryLeadingZeros": "不允许前导零", "settings.ipPolicyEntryMultipleSlashes": "只能包含一个“/”", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "前缀长度必须为数字", "settings.ipPolicyEntryPrefixRange": "前缀长度必须在 0–{{max}} 之间", "settings.ipPolicyEntryUnsupported": "仅支持 IPv4 / IPv6 地址和 CIDR 网段", + "settings.ipPolicyMore": "更多", "settings.ipPolicyName": "名称", "settings.ipPolicyNameMax": "名称不能超过 {{max}} 个字符", "settings.ipPolicyNamePlaceholder": "例:内部网络", "settings.ipPolicyNewTitle": "新建 IP 策略", "settings.ipPolicyRemoveEntry": "删除条目", - "settings.ipPolicyUsedBy": "已被 {{count}} 个应用使用", - "settings.ipPolicyUsedByLabel": "被用于", - "settings.ipPolicyUsedBy_one": "已被 {{count}} 个应用使用", + "settings.ipPolicyUsedBy": "仍有 {{count}} 个应用在引用", + "settings.ipPolicyUsedByLabel": "生效中", + "settings.ipPolicyUsedBy_one": "仍有 {{count}} 个应用在引用", "settings.members": "成员", "settings.permissionSet": "权限集", "settings.permissionSetDescription": "配置可供应用和知识库使用的权限集。权限集是一组可复用的资源操作权限,可针对特定资源分配给成员。", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index b2adeeaa57e11a..44f86892ebb938 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -35,15 +35,17 @@ "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", "studio.accessControl.addressCount": "{{count}} 个地址", "studio.accessControl.applyTo": "应用于", - "studio.accessControl.applyToHelp": "选择要保护的访问点。", - "studio.accessControl.applyToHelpSelected": "选择此策略要保护的访问点。", - "studio.accessControl.chipOff": "Off", + "studio.accessControl.applyToHelp": "选择此策略保护的访问入口。", + "studio.accessControl.applyToHelpSelected": "选择此策略保护的访问入口。", + "studio.accessControl.chipOff": "关闭", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", - "studio.accessControl.createIpPolicy": "创建 IP 策略", + "studio.accessControl.createIpPolicy": "添加 IP 策略", + "studio.accessControl.description": "通过应用 IP 策略来控制对当前应用的访问。", "studio.accessControl.downgradeDescription": "访问控制需要 Pro 套餐。", "studio.accessControl.downgradeTitle": "此应用已不再受保护", "studio.accessControl.emptyPoliciesTitle": "当前工作空间还没有 IP 策略", + "studio.accessControl.enableAccessControl": "启用访问控制", "studio.accessControl.entryLabel": "访问控制", "studio.accessControl.excluded": "未覆盖", "studio.accessControl.ipPolicy": "IP 策略", @@ -59,19 +61,20 @@ "studio.accessControl.previewAppName": "代码助手", "studio.accessControl.previewCaption": "此应用仅允许在组织网络中访问。", "studio.accessControl.proBadge": "PRO", - "studio.accessControl.protectingAll": "正在保护全部 {{count}} 个在服务中的访问点。", - "studio.accessControl.protectingPartial": "正在保护 {{n}} / {{m}} 个在服务中的访问点。", - "studio.accessControl.protectsCoverage": "保护 {{n}} / {{m}} 个访问点", + "studio.accessControl.protectingAll": "正在保护全部 {{count}} 个在服务中的访问入口。", + "studio.accessControl.protectingPartial": "正在保护 {{n}} / {{m}} 个在服务中的访问入口。", + "studio.accessControl.protectsCoverage": "保护 {{n}} / {{m}} 个访问入口", "studio.accessControl.readyToResume": "可恢复", "studio.accessControl.restrictByIp": "按 IP 地址限制", "studio.accessControl.restrictedTo": "已限制为 {{name}}", "studio.accessControl.saveAnyway": "仍要保存", "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情况下保存?", - "studio.accessControl.selectAccessPoint": "请至少选择一个要保护的访问点。", - "studio.accessControl.selectPolicy": "选择策略", + "studio.accessControl.selectAccessPoint": "请至少选择一个访问入口。", + "studio.accessControl.selectPolicy": "选择 IP 策略", + "studio.accessControl.selectPolicyRequired": "请选择一条 IP 策略。", "studio.accessControl.tooltipOff": "尚未配置", - "studio.accessControl.tooltipOn": "{{name}},覆盖全部访问点", - "studio.accessControl.tooltipPartial": "{{name}},{{n}} / {{m}} 个访问点", + "studio.accessControl.tooltipOn": "全部访问入口", + "studio.accessControl.tooltipPartial": "{{total}} 个访问入口中的 {{n}} 个", "studio.accessControl.tooltipPaused": "已暂停 — {{name}} 已配置但未生效", "studio.accessControl.tooltipPro": "访问控制需要 Pro 套餐", "studio.accessControl.turnOffConfirm": "关闭", @@ -79,11 +82,11 @@ "studio.accessControl.turnOffTitle": "关闭访问控制?", "studio.accessControl.turnOn": "开启访问控制", "studio.accessPoint.apiDescription": "通过 HTTP 将应用集成到你的服务中", - "studio.accessPoint.description": "此应用的所有访问方式。访问点仅对应当前运行版本。", + "studio.accessPoint.description": "此应用的所有访问方式。访问入口仅对应当前运行版本。", "studio.accessPoint.embedIntoSite": "嵌入网站", "studio.accessPoint.goToPublish": "去发布", "studio.accessPoint.mcpDescription": "将应用作为工具提供给 Agent 和 AI 应用", - "studio.accessPoint.noPublishedDescription": "发布一个版本后即可使用内置访问点。", + "studio.accessPoint.noPublishedDescription": "发布一个版本后即可使用内置访问入口。", "studio.accessPoint.noPublishedTitle": "尚未发布任何版本", "studio.accessPoint.notSupported": "不支持", "studio.accessPoint.open": "打开", @@ -92,7 +95,7 @@ "studio.accessPoint.triggerExclusiveNotice": "使用触发器时,Web 应用、API 访问和 MCP 服务不可用。", "studio.accessPoint.unsupportedInDeployedEnvironment": "仅在内置环境中可用。", "studio.accessPoint.webAppDescription": "通过托管网页使用应用", - "studio.accessPoints": "访问点", + "studio.accessPoints": "访问入口", "studio.activity.deploy": "部署 {{target}}", "studio.activity.deployFailed": "部署 {{target}} 失败", "studio.activity.deploySucceeded": "部署 {{target}} 成功", @@ -126,7 +129,7 @@ "studio.publisher.noPublishedDescription": "发布草稿后即可在此部署。", "studio.publisher.notDeployedYet": "尚未部署", "studio.retryVersion": "重试 {{version}}", - "studio.undeployConfirmDesc": "该应用将停止在此环境中运行,其所有访问点都将无法使用。", + "studio.undeployConfirmDesc": "该应用将停止在此环境中运行,其所有访问入口都将无法使用。", "studio.undeployConfirmTitle": "从 {{envName}} 下线 {{versionName}}", "studio.updatedAtBy": "{{name}} 于 {{time}} 更新", "studio.versionValue": "版本值", diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json index 7b69fca42ab843..af2485011ff2e9 100644 --- a/web/i18n/zh-Hans/permission-keys.json +++ b/web/i18n/zh-Hans/permission-keys.json @@ -1,7 +1,7 @@ { "agent.acl.access_config": "查看与管理访问权限", - "agent.acl.access_point_manage": "管理访问点", - "agent.acl.access_point_view": "查看访问点", + "agent.acl.access_point_manage": "管理访问入口", + "agent.acl.access_point_view": "查看访问入口", "agent.acl.delete": "删除 Agent", "agent.acl.edit": "编辑 Agent 信息与配置", "agent.acl.import_export_dsl": "导入与导出 Agent DSL", @@ -14,8 +14,8 @@ "api_extension.manage": "管理API扩展", "app.access_config": "配置应用访问权限", "app.acl.access_config": "查看与管理访问权限", - "app.acl.access_point_manage": "管理访问点", - "app.acl.access_point_view": "查看访问点", + "app.acl.access_point_manage": "管理访问入口", + "app.acl.access_point_view": "查看访问入口", "app.acl.delete": "删除应用", "app.acl.deploy": "部署应用", "app.acl.edit": "编辑应用信息与编排应用", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 8e2c4ec7968c6d..35380afe34ff14 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -571,12 +571,12 @@ "settings.ipPolicyCreate": "建立", "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "該策略正在使用中", - "settings.ipPolicyDeleteBoundDescription": "確定刪除 {{name}}?這些應用程式將允許從任何 IP 位址存取。此操作無法復原。", - "settings.ipPolicyDeleteConfirm": "刪除 IP 原則?", - "settings.ipPolicyDeleteDescription": "確定刪除 {{name}}?此操作無法復原。", + "settings.ipPolicyDeleteBoundDescription": "這些應用程式將允許從任何 IP 位址存取。此操作無法復原。", + "settings.ipPolicyDeleteConfirm": "確定刪除 “{{name}}”?", + "settings.ipPolicyDeleteDescription": "此操作無法復原。", "settings.ipPolicyDialogDescription": "指定允許存取的 IP 位址或網段。", "settings.ipPolicyEditTitle": "編輯 IP 策略", - "settings.ipPolicyEditUsedBy": "該策略已被 {{count}} 個應用使用。儲存後會更新所有這些應用的允許清單。", + "settings.ipPolicyEditUsedBy": "修改將立即對所有套用此原則的應用程式生效。", "settings.ipPolicyEnforcingMany": "{{count}} 個應用", "settings.ipPolicyEnforcingNone": "0 個應用", "settings.ipPolicyEnforcingOne": "1 個應用", @@ -587,14 +587,15 @@ "settings.ipPolicyEntryPrefixNotNumber": "前置碼長度必須為數字", "settings.ipPolicyEntryPrefixRange": "前置碼長度必須在 0–{{max}} 之間", "settings.ipPolicyEntryUnsupported": "僅支援 IPv4 / IPv6 位址和 CIDR 網段", + "settings.ipPolicyMore": "更多", "settings.ipPolicyName": "名稱", "settings.ipPolicyNameMax": "名稱不能超過 {{max}} 個字元", "settings.ipPolicyNamePlaceholder": "例:內部網路", "settings.ipPolicyNewTitle": "新增 IP 策略", "settings.ipPolicyRemoveEntry": "刪除條目", - "settings.ipPolicyUsedBy": "已被 {{count}} 個應用使用", + "settings.ipPolicyUsedBy": "仍有 {{count}} 個應用程式在使用", "settings.ipPolicyUsedByLabel": "被用於", - "settings.ipPolicyUsedBy_one": "已被 {{count}} 個應用使用", + "settings.ipPolicyUsedBy_one": "仍有 {{count}} 個應用程式在使用", "settings.members": "成員", "settings.permissionSet": "權限集", "settings.permissionSetDescription": "配置可供應用和知識庫使用的權限集。權限集是一組可重複使用的資源操作權限,可針對特定資源分配給成員。", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index 901d1b947dc1c2..9bbe0d13bf7ce2 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -40,10 +40,12 @@ "studio.accessControl.chipOff": "Off", "studio.accessControl.chipOn": "ON", "studio.accessControl.chipPartial": "{{n}} / {{m}}", - "studio.accessControl.createIpPolicy": "建立 IP 策略", + "studio.accessControl.createIpPolicy": "新增 IP 原則", + "studio.accessControl.description": "透過套用 IP 原則來控制對此應用程式的存取。", "studio.accessControl.downgradeDescription": "Access control needs the Pro plan.", "studio.accessControl.downgradeTitle": "This app is no longer protected", "studio.accessControl.emptyPoliciesTitle": "目前工作區還沒有 IP 策略", + "studio.accessControl.enableAccessControl": "啟用存取控制", "studio.accessControl.entryLabel": "Access Control", "studio.accessControl.excluded": "未涵蓋", "studio.accessControl.ipPolicy": "IP 策略", @@ -69,9 +71,10 @@ "studio.accessControl.saveWithoutOwnIp": "在不含你自己的 IP 的情況下儲存?", "studio.accessControl.selectAccessPoint": "請至少選擇一個要保護的存取點。", "studio.accessControl.selectPolicy": "選擇策略", + "studio.accessControl.selectPolicyRequired": "請選擇一項 IP 原則。", "studio.accessControl.tooltipOff": "尚未設定", - "studio.accessControl.tooltipOn": "{{name}},涵蓋全部存取點", - "studio.accessControl.tooltipPartial": "{{name}},{{n}} / {{m}} 個存取點", + "studio.accessControl.tooltipOn": "涵蓋全部存取點", + "studio.accessControl.tooltipPartial": "{{n}} / {{total}} 個存取點", "studio.accessControl.tooltipPaused": "已暫停 — {{name}} 已設定但未生效", "studio.accessControl.tooltipPro": "存取控制需要 Pro 方案", "studio.accessControl.turnOffConfirm": "關閉", From bbfca74704b7601a91e05e0c38f2403aa114ec2b Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:27:40 +0800 Subject: [PATCH 043/108] fix(web): open IP policies for editing from rows (SNP-833) --- .../ip-policies-page/__tests__/index.spec.tsx | 25 ++++++++++++ .../ip-policies-page/index.tsx | 2 +- .../ip-policies-page/policy-item.tsx | 38 +++++++++---------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index c5bf41554886fe..44df2502c99435 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -516,4 +516,29 @@ describe('IpPoliciesPage', () => { } }, ) + it.each(['owner', 'admin'] as const)( + 'opens edit directly from the policy row for %s', + async (role) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + expect(screen.getByRole('dialog', { name: 'Edit IP Policy' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Internal Network') + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }, + ) }) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index b18d2c9c216747..be919086ca95f4 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -156,7 +156,7 @@ export default function IpPoliciesPage() { key={group.id} group={group} canMutate={canMutate} - onView={(group) => setDialogState({ mode: 'view', group })} + onView={(group) => setDialogState({ mode: canMutate ? 'edit' : 'view', group })} onEdit={(group) => { if (!canMutate) return setDialogState({ mode: 'edit', group }) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx index 4c647e51bf93df..572c546281ab7c 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -78,25 +78,25 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps return (
-
- -
-

- {group.allowed_cidrs.length} -

-

- {enforcingLabel} -

-

- {formatTimeFromNow(Date.parse(group.updated_at))} -

+
{canMutate && ( From 94a15b17d6dd9c6722df289339430cb895956fc5 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:28:19 +0800 Subject: [PATCH 044/108] fix(web): label IP policy counts as total references (SNP-828) --- .../ip-policies-page/__tests__/index.spec.tsx | 26 +++++++++++++++++++ .../ip-policies-page/index.tsx | 8 +++--- .../ip-policies-page/policy-item.tsx | 8 +++--- web/i18n/ar-TN/common.json | 2 +- web/i18n/de-DE/common.json | 2 +- web/i18n/en-US/common.json | 2 +- web/i18n/es-ES/common.json | 2 +- web/i18n/fa-IR/common.json | 2 +- web/i18n/fr-FR/common.json | 2 +- web/i18n/hi-IN/common.json | 2 +- web/i18n/id-ID/common.json | 2 +- web/i18n/it-IT/common.json | 2 +- web/i18n/ja-JP/common.json | 2 +- web/i18n/ko-KR/common.json | 2 +- web/i18n/lo-LA/common.json | 2 +- web/i18n/nl-NL/common.json | 2 +- web/i18n/pl-PL/common.json | 2 +- web/i18n/pt-BR/common.json | 2 +- web/i18n/ro-RO/common.json | 2 +- web/i18n/ru-RU/common.json | 2 +- web/i18n/sl-SI/common.json | 2 +- web/i18n/th-TH/common.json | 2 +- web/i18n/tr-TR/common.json | 2 +- web/i18n/uk-UA/common.json | 2 +- web/i18n/vi-VN/common.json | 2 +- web/i18n/zh-Hans/common.json | 2 +- web/i18n/zh-Hant/common.json | 2 +- 27 files changed, 58 insertions(+), 32 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index 44df2502c99435..b8822d6aec54b3 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -541,4 +541,30 @@ describe('IpPoliciesPage', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }, ) + + it.each([0, 1])( + 'shows total references under Used by even when only %s apps enforce the policy', + (enforcingCount) => { + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + groups: [ + createNetworkAccessGroupFixture({ used_by_count: 2, enforcing_count: enforcingCount }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + expect(screen.getByText('Used by')).toBeInTheDocument() + expect( + within(screen.getByRole('button', { name: 'Internal Network' })).getByText('2 apps'), + ).toBeInTheDocument() + expect(screen.queryByText('Enforcing')).not.toBeInTheDocument() + }, + ) }) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index be919086ca95f4..f6735586f8eace 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -21,12 +21,12 @@ import { consoleQuery } from '@/service/console' import { IpPolicyDialog } from './policy-dialog' import { policyActionsColClassName, - policyEnforcingColClassName, policyIpEntriesColClassName, PolicyItem, policyNameColClassName, policyRowClassName, policyUpdatedColClassName, + policyUsedByColClassName, } from './policy-item' type DialogState = @@ -47,7 +47,7 @@ function IpPoliciesListSkeleton() { - +
@@ -143,8 +143,8 @@ export default function IpPoliciesPage() {
{t(($) => $['settings.ipPolicyColumnIpEntries'], { ns: 'common' })}
-
- {t(($) => $['settings.ipPolicyColumnEnforcing'], { ns: 'common' })} +
+ {t(($) => $['settings.ipPolicyColumnUsedBy'], { ns: 'common' })}
{t(($) => $['settings.ipPolicyColumnUpdatedAt'], { ns: 'common' })} diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx index 572c546281ab7c..512e5f4858f7ec 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -30,7 +30,7 @@ import { PolicyReferencedApps } from './referenced-apps' export const policyRowClassName = 'flex items-center pl-3 pr-1' export const policyNameColClassName = 'min-w-0 flex-1 truncate' export const policyIpEntriesColClassName = 'w-30 shrink-0' -export const policyEnforcingColClassName = 'w-32 shrink-0' +export const policyUsedByColClassName = 'w-32 shrink-0' export const policyUpdatedColClassName = 'w-52 shrink-0' export const policyActionsColClassName = 'flex w-8 shrink-0 items-center justify-center' @@ -50,7 +50,7 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps consoleQuery.workspaces.current.networkAccessGroups.byGroupId.delete.mutationOptions(), ) const isBound = group.used_by_count > 0 - const enforcingLabel = + const usedByLabel = group.used_by_count <= 0 ? t(($) => $['settings.ipPolicyEnforcingNone'], { ns: 'common' }) : group.used_by_count === 1 @@ -90,8 +90,8 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps {group.allowed_cidrs.length} - - {enforcingLabel} + + {usedByLabel} {formatTimeFromNow(Date.parse(group.updated_at))} diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index 231116dd993451..d539b465c50a0c 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "ساري", "settings.ipPolicyColumnIpEntries": "إدخالات IP", "settings.ipPolicyColumnName": "الاسم", "settings.ipPolicyColumnUpdatedAt": "آخر تحديث", + "settings.ipPolicyColumnUsedBy": "مستخدمة في", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 759b67d71ddbd3..2ee75494d14e2a 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Aktiv", "settings.ipPolicyColumnIpEntries": "IP-Einträge", "settings.ipPolicyColumnName": "Name", "settings.ipPolicyColumnUpdatedAt": "Aktualisiert", + "settings.ipPolicyColumnUsedBy": "Verwendet von", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 09aa5bab7da4ca..255fc1bc49f227 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Used by", "settings.ipPolicyColumnIpEntries": "IP Entries", "settings.ipPolicyColumnName": "Name", "settings.ipPolicyColumnUpdatedAt": "Updated at", + "settings.ipPolicyColumnUsedBy": "Used by", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 99a54d0d05ce18..bd93463e067af6 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "En vigor", "settings.ipPolicyColumnIpEntries": "Entradas IP", "settings.ipPolicyColumnName": "Nombre", "settings.ipPolicyColumnUpdatedAt": "Actualizado", + "settings.ipPolicyColumnUsedBy": "Usada por", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 33f03ab1fc9f38..d88fba7c3a8d34 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "در حال اعمال", "settings.ipPolicyColumnIpEntries": "ورودی‌های IP", "settings.ipPolicyColumnName": "نام", "settings.ipPolicyColumnUpdatedAt": "به‌روزرسانی", + "settings.ipPolicyColumnUsedBy": "استفاده‌شده توسط", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 4af27aa343784d..7a29c35f95e380 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "En vigueur", "settings.ipPolicyColumnIpEntries": "Entrées IP", "settings.ipPolicyColumnName": "Nom", "settings.ipPolicyColumnUpdatedAt": "Mis à jour", + "settings.ipPolicyColumnUsedBy": "Utilisée par", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 87007acbd7c364..70a8b48d929266 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "लागू", "settings.ipPolicyColumnIpEntries": "IP प्रविष्टियाँ", "settings.ipPolicyColumnName": "नाम", "settings.ipPolicyColumnUpdatedAt": "अपडेट किया गया", + "settings.ipPolicyColumnUsedBy": "उपयोगकर्ता ऐप", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 32bd54689d8578..bf5065d9374d72 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Berlaku", "settings.ipPolicyColumnIpEntries": "Entri IP", "settings.ipPolicyColumnName": "Nama", "settings.ipPolicyColumnUpdatedAt": "Diperbarui", + "settings.ipPolicyColumnUsedBy": "Digunakan oleh", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 82e023693039a9..13534cdeda38ea 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "In vigore", "settings.ipPolicyColumnIpEntries": "Voci IP", "settings.ipPolicyColumnName": "Nome", "settings.ipPolicyColumnUpdatedAt": "Aggiornato", + "settings.ipPolicyColumnUsedBy": "Utilizzata da", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index b144ff25f6aae7..bdd89f69c084b3 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "許可リスト", "settings.ipPolicyAllowlistHelp": "単一のアドレス(203.0.113.42)または CIDR 範囲(10.0.0.0/8)を入力できます。IPv4 と IPv6 の両方に対応しています。", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "使用中のアプリ", "settings.ipPolicyColumnIpEntries": "IP エントリ", "settings.ipPolicyColumnName": "名前", "settings.ipPolicyColumnUpdatedAt": "更新日時", + "settings.ipPolicyColumnUsedBy": "使用中のアプリ", "settings.ipPolicyCreate": "作成", "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 543a0d863f5d53..b0574c9299539f 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "적용 중", "settings.ipPolicyColumnIpEntries": "IP 항목", "settings.ipPolicyColumnName": "이름", "settings.ipPolicyColumnUpdatedAt": "업데이트", + "settings.ipPolicyColumnUsedBy": "사용 중인 앱", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index e9e78ff4372bb2..fe18c6e335ea05 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "ກຳລັງນຳໃຊ້", "settings.ipPolicyColumnIpEntries": "ລາຍການ IP", "settings.ipPolicyColumnName": "ຊື່", "settings.ipPolicyColumnUpdatedAt": "ອັບເດດ", + "settings.ipPolicyColumnUsedBy": "ນຳໃຊ້ໂດຍ", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 3c18334f979ffa..2237db959bc052 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Actief", "settings.ipPolicyColumnIpEntries": "IP-vermeldingen", "settings.ipPolicyColumnName": "Naam", "settings.ipPolicyColumnUpdatedAt": "Bijgewerkt", + "settings.ipPolicyColumnUsedBy": "Gebruikt door", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index e0fb110a0664e0..420b500ed0ce2b 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Wymuszane", "settings.ipPolicyColumnIpEntries": "Wpisy IP", "settings.ipPolicyColumnName": "Nazwa", "settings.ipPolicyColumnUpdatedAt": "Zaktualizowano", + "settings.ipPolicyColumnUsedBy": "Używana przez", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index dc04f0e7886ce0..e5935c980aa8fd 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Em vigor", "settings.ipPolicyColumnIpEntries": "Entradas de IP", "settings.ipPolicyColumnName": "Nome", "settings.ipPolicyColumnUpdatedAt": "Atualizado em", + "settings.ipPolicyColumnUsedBy": "Usada por", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index abaefcb955cf76..6695bec7dd988c 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "În vigoare", "settings.ipPolicyColumnIpEntries": "Intrări IP", "settings.ipPolicyColumnName": "Nume", "settings.ipPolicyColumnUpdatedAt": "Actualizat", + "settings.ipPolicyColumnUsedBy": "Utilizată de", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index d8d58135d0b9ca..83a3d3f4555ac8 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Применяется", "settings.ipPolicyColumnIpEntries": "IP-записи", "settings.ipPolicyColumnName": "Название", "settings.ipPolicyColumnUpdatedAt": "Обновлено", + "settings.ipPolicyColumnUsedBy": "Используется", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index e134c85acfacf0..5c0a5ddde4e2f8 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "V veljavi", "settings.ipPolicyColumnIpEntries": "IP vnosi", "settings.ipPolicyColumnName": "Ime", "settings.ipPolicyColumnUpdatedAt": "Posodobljeno", + "settings.ipPolicyColumnUsedBy": "Uporabljajo", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 95ad2134ef7f05..1ccc41c6c01111 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "กำลังบังคับใช้", "settings.ipPolicyColumnIpEntries": "รายการ IP", "settings.ipPolicyColumnName": "ชื่อ", "settings.ipPolicyColumnUpdatedAt": "อัปเดตเมื่อ", + "settings.ipPolicyColumnUsedBy": "ใช้งานโดย", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 54522a58c36b4d..5b1b711cd70416 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Uygulanıyor", "settings.ipPolicyColumnIpEntries": "IP kayıtları", "settings.ipPolicyColumnName": "Ad", "settings.ipPolicyColumnUpdatedAt": "Güncellendi", + "settings.ipPolicyColumnUsedBy": "Kullanan uygulamalar", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 1e1babdfae7727..d4846344bad5e3 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Застосовується", "settings.ipPolicyColumnIpEntries": "IP-записи", "settings.ipPolicyColumnName": "Назва", "settings.ipPolicyColumnUpdatedAt": "Оновлено", + "settings.ipPolicyColumnUsedBy": "Використовується", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index b325400c353dfc..8dac2c4e41bc26 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "Allowlist", "settings.ipPolicyAllowlistHelp": "Single addresses (203.0.113.42) or CIDR ranges (10.0.0.0/8). IPv4 and IPv6 are both accepted.", "settings.ipPolicyCidrMax": "A policy can include up to {{max}} addresses", - "settings.ipPolicyColumnEnforcing": "Đang áp dụng", "settings.ipPolicyColumnIpEntries": "Mục IP", "settings.ipPolicyColumnName": "Tên", "settings.ipPolicyColumnUpdatedAt": "Cập nhật", + "settings.ipPolicyColumnUsedBy": "Được sử dụng bởi", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", "settings.ipPolicyDeleteBound": "This policy is in use", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index e04fb4873ac932..f3ba706da4498a 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "允许列表", "settings.ipPolicyAllowlistHelp": "支持单个地址(203.0.113.42)或 CIDR 网段(10.0.0.0/8),IPv4 与 IPv6 均可。", "settings.ipPolicyCidrMax": "一条策略最多包含 {{max}} 个地址", - "settings.ipPolicyColumnEnforcing": "生效应用", "settings.ipPolicyColumnIpEntries": "IP 条目", "settings.ipPolicyColumnName": "名称", "settings.ipPolicyColumnUpdatedAt": "更新时间", + "settings.ipPolicyColumnUsedBy": "生效应用", "settings.ipPolicyCreate": "创建", "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "该策略正在使用中", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 35380afe34ff14..24ceadb93c832d 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -564,10 +564,10 @@ "settings.ipPolicyAllowlist": "允許清單", "settings.ipPolicyAllowlistHelp": "支援單一地址(203.0.113.42)或 CIDR 網段(10.0.0.0/8),IPv4 與 IPv6 皆可。", "settings.ipPolicyCidrMax": "一條策略最多包含 {{max}} 個地址", - "settings.ipPolicyColumnEnforcing": "生效中", "settings.ipPolicyColumnIpEntries": "IP 項目", "settings.ipPolicyColumnName": "名稱", "settings.ipPolicyColumnUpdatedAt": "更新時間", + "settings.ipPolicyColumnUsedBy": "使用中的應用程式", "settings.ipPolicyCreate": "建立", "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", "settings.ipPolicyDeleteBound": "該策略正在使用中", From e58d4f3ff6290853d89bff4b2bc1232f68c28fde Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:28:27 +0800 Subject: [PATCH 045/108] fix(web): route IP policy references to bound agents (SNP-829) --- .../ip-policies-page/__tests__/index.spec.tsx | 61 +++++++++++++++++++ .../ip-policies-page/referenced-apps.tsx | 6 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index b8822d6aec54b3..a4935daa693504 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -567,4 +567,65 @@ describe('IpPoliciesPage', () => { expect(screen.queryByText('Enforcing')).not.toBeInTheDocument() }, ) + + it.each(['view', 'edit', 'delete'] as const)( + 'links agent and regular app references correctly in %s', + async (mode) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + enforcing_count: 1, + apps: [ + { + id: 'backing-app', + bound_agent_id: 'agent-1', + name: 'New Agent', + mode: 'workflow', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-2', + name: 'Workflow', + mode: 'workflow', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: mode === 'view' ? 'editor' : 'owner' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + if (mode === 'delete') { + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + expect(screen.getByText('Still used by 2 apps')).toBeInTheDocument() + } else { + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + } + expect(screen.getByRole('link', { name: 'New Agent' })).toHaveAttribute( + 'href', + '/agents/agent-1/configure', + ) + expect(screen.getByRole('link', { name: 'New Agent' })).toHaveAttribute('target', '_blank') + expect(screen.getByRole('link', { name: 'Workflow' })).toHaveAttribute( + 'href', + '/app/app-2/overview', + ) + }, + ) }) diff --git a/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx b/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx index de2788e000aabb..bbfa533eb6a54d 100644 --- a/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/referenced-apps.tsx @@ -32,7 +32,11 @@ export function PolicyReferencedApps({ apps, usedByCount }: PolicyReferencedApps {apps.map((app) => (
  • Date: Sun, 20 Sep 2026 17:28:35 +0800 Subject: [PATCH 046/108] fix(web): accept IPv4-mapped IPv6 policy entries (SNP-830) --- .../__tests__/policy-dialog.spec.tsx | 25 ++++++++++++++++++ .../__tests__/validate-ip-entry.spec.ts | 26 +++++++++++++++++++ .../ip-policies-page/validate-ip-entry.ts | 11 ++++++-- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx index d139e3a0a1bf9a..15c17c3b6063f7 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx @@ -111,3 +111,28 @@ describe('IpPolicyDialog', () => { }) }) }) + +describe('trusted current IP and allowlist editing', () => { + it.each(['create', 'edit'] as const)( + 'accepts a mapped IPv6 address in %s mode and submits it to the server', + async (mode) => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: mode === 'create' ? 'Create' : 'Save' })) + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Office', + allowed_cidrs: ['::ffff:203.0.113.42/128'], + }) + }, + ) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts index 7013432893bdbc..c9256cde97a131 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts @@ -71,6 +71,32 @@ describe('validateIpEntry', () => { expect(validateIpEntry('10.0.0.*')).toEqual({ kind: 'invalid', code: 'unsupported' }) expect(validateIpEntry('office.example.com')).toEqual({ kind: 'invalid', code: 'unsupported' }) }) + + it.each([ + '::ffff:203.0.113.42/128', + '::ffff:203.0.113.0/120', + '0:0:0:0:0:ffff:203.0.113.42', + '::ffff:cb00:712a/128', + '2001:db8::192.0.2.1/128', + '::/0', + ])('accepts valid IPv6 notation also accepted by the backend: %s', (entry) => { + expect(validateIpEntry(entry)).toEqual({ kind: 'valid' }) + expect(canSubmitIpPolicy('Office', [entry])).toBe(true) + }) + + it.each([ + '::ffff:203.0.113.256', + '::ffff:203.0.113.01', + '::ffff:203.0.113', + '::ffff::203.0.113.42', + '1:2:3:4:5:6:7:203.0.113.42', + '::ffff:203.0.113.42:1', + '::ffff:203.0.113.42/129', + '::ffff:203.0.113.42/128/1', + ])('blocks malformed embedded IPv4 or CIDR: %s', (entry) => { + expect(validateIpEntry(entry).kind).toBe('invalid') + expect(canSubmitIpPolicy('Office', [entry])).toBe(false) + }) }) describe('canSubmitIpPolicy', () => { diff --git a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts index fdcc592e01384e..6885d52c1a9eaa 100644 --- a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts +++ b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts @@ -37,7 +37,15 @@ function parseIpv4(address: string): 'valid' | 'leadingZeros' | 'octetRange' | ' } function parseIpv6(address: string): boolean { - if (address.includes('.')) return false + if (address.includes('.')) { + const lastColon = address.lastIndexOf(':') + const embeddedIpv4 = address.slice(lastColon + 1) + if (parseIpv4(embeddedIpv4) !== 'valid') return false + const octets = embeddedIpv4.split('.').map(Number) + const high = ((octets[0] ?? 0) * 256 + (octets[1] ?? 0)).toString(16) + const low = ((octets[2] ?? 0) * 256 + (octets[3] ?? 0)).toString(16) + address = `${address.slice(0, lastColon + 1)}${high}:${low}` + } const sides = address.split('::') if (sides.length > 2) return false @@ -138,7 +146,6 @@ export function splitPolicySummary(addresses: readonly string[]): { if (addresses.length <= 2) return { listed: addresses, moreCount: 0 } return { listed: addresses.slice(0, 2), moreCount: addresses.length - 2 } } - function ipv4ToInt(ip: string): number | null { const parts = ip.split('.') if (parts.length !== 4) return null From b2a7219076316161ad2af688dc364c37e428823c Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:29:46 +0800 Subject: [PATCH 047/108] fix(web): add trusted current IP to policy allowlists (SNP-835) --- .../access-control/__tests__/index.spec.tsx | 31 ++++++- .../ip-policies-page/__tests__/index.spec.tsx | 10 ++- .../__tests__/policy-dialog.spec.tsx | 82 ++++++++++++++++++- .../__tests__/validate-ip-entry.spec.ts | 19 +++++ .../ip-policies-page/allowlist-field.tsx | 43 ++++++++-- .../ip-policies-page/policy-dialog.tsx | 29 ++++++- .../ip-policies-page/validate-ip-entry.ts | 20 +++++ web/i18n/ar-TN/common.json | 2 + web/i18n/de-DE/common.json | 2 + web/i18n/en-US/common.json | 2 + web/i18n/es-ES/common.json | 2 + web/i18n/fa-IR/common.json | 2 + web/i18n/fr-FR/common.json | 2 + web/i18n/hi-IN/common.json | 2 + web/i18n/id-ID/common.json | 2 + web/i18n/it-IT/common.json | 2 + web/i18n/ja-JP/common.json | 2 + web/i18n/ko-KR/common.json | 2 + web/i18n/lo-LA/common.json | 2 + web/i18n/nl-NL/common.json | 2 + web/i18n/pl-PL/common.json | 2 + web/i18n/pt-BR/common.json | 2 + web/i18n/ro-RO/common.json | 2 + web/i18n/ru-RU/common.json | 2 + web/i18n/sl-SI/common.json | 2 + web/i18n/th-TH/common.json | 2 + web/i18n/tr-TR/common.json | 2 + web/i18n/uk-UA/common.json | 2 + web/i18n/vi-VN/common.json | 2 + web/i18n/zh-Hans/common.json | 2 + web/i18n/zh-Hant/common.json | 2 + 31 files changed, 267 insertions(+), 15 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 922742844ab43f..661b99953a4e04 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -104,6 +104,18 @@ const renderEntry = ({ ) } +const metadataResponse = (request: Request) => { + if (request.url.endsWith('/network-access-groups/current-ip')) + return Response.json({ client_ip: '203.0.113.42' }) +} + +beforeEach(() => { + const fallback = vi.mocked(globalThis.fetch).getMockImplementation()! + vi.mocked(globalThis.fetch).mockImplementation( + async (input, init) => metadataResponse(new Request(input, init)) ?? fallback(input, init), + ) +}) + const getChip = () => screen.getByRole('button', { name: /Access Control/ }) describe('AccessControlEntry', () => { @@ -279,6 +291,8 @@ describe('AccessControlEntry', () => { const posted: Array<{ method: string; url: string; body: unknown }> = [] vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(String(input), init) + const metadata = metadataResponse(request) + if (metadata) return metadata const bodyText = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text() posted.push({ @@ -286,10 +300,19 @@ describe('AccessControlEntry', () => { url: request.url, body: bodyText ? JSON.parse(bodyText) : null, }) - return new Response(JSON.stringify({ group: created }), { - status: 201, - headers: { 'content-type': 'application/json' }, - }) + if (request.method === 'GET') { + return request.url.endsWith('/network-access-groups') + ? Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [created] }) + : Response.json({ + tenant_id: 'workspace-1', + app_id: 'app-1', + entitled: true, + binding: null, + available_access_points: ['webapp', 'service_api', 'mcp'], + effective_enabled: false, + }) + } + return Response.json({ group: created }, { status: 201 }) }) renderEntry({ plan: 'professional' }) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx index a4935daa693504..824c6ace354746 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -18,6 +18,9 @@ vi.unmock('react-i18next') describe('IpPoliciesPage', () => { beforeEach(async () => { vi.clearAllMocks() + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ client_ip: '203.0.113.42' }), + ) await createInstance() .use(initReactI18next) .init({ @@ -207,7 +210,11 @@ describe('IpPoliciesPage', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() } - expect(globalThis.fetch).not.toHaveBeenCalled() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.filter(([input, init]) => new Request(input, init).method !== 'GET'), + ).toHaveLength(0) }, ) @@ -516,6 +523,7 @@ describe('IpPoliciesPage', () => { } }, ) + it.each(['owner', 'admin'] as const)( 'opens edit directly from the policy row for %s', async (role) => { diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx index 15c17c3b6063f7..e1376f42683fb3 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-dialog.spec.tsx @@ -1,7 +1,7 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { IpPolicyDialog } from '../policy-dialog' const translations = vi.hoisted(() => ({ @@ -26,7 +26,8 @@ const translations = vi.hoisted(() => ({ vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') - return createReactI18nextMock(translations) + const { default: commonTranslations } = await import('@/i18n/en-US/common.json') + return createReactI18nextMock({ ...commonTranslations, ...translations }) }) function DialogHarness({ @@ -49,6 +50,11 @@ function DialogHarness({ } describe('IpPolicyDialog', () => { + beforeEach(() => { + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ client_ip: '203.0.113.42' }), + ) + }) it('keeps Create disabled until the name and a valid entry are filled', async () => { const user = userEvent.setup() render() @@ -113,6 +119,78 @@ describe('IpPolicyDialog', () => { }) describe('trusted current IP and allowlist editing', () => { + beforeEach(() => { + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ client_ip: '203.0.113.42' }), + ) + }) + + it('loads the current IP without an existing policy and preserves input when adding it', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render() + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + expect(await screen.findByText('Your current IP is 203.0.113.42')).toBeInTheDocument() + const request = new Request(vi.mocked(globalThis.fetch).mock.calls[0]![0]) + expect(request.method).toBe('GET') + expect(request.url).toBe( + 'http://localhost:5001/console/api/workspaces/current/network-access-groups/current-ip', + ) + await user.click(screen.getByRole('button', { name: 'Add it' })) + expect( + screen.getAllByPlaceholderText('10.0.0.0/8').map((row) => (row as HTMLInputElement).value), + ).toEqual(['10.0.0.0/8', '203.0.113.42']) + expect(screen.getByRole('button', { name: 'Add it' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('shows a retryable error instead of a guessed IP when the trusted endpoint returns 503', async () => { + const user = userEvent.setup() + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + Response.json({ message: 'IP unavailable' }, { status: 503 }), + ) + render() + expect( + await screen.findByText('Unable to check your IP address. Please try again.'), + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add it' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Your current IP is 203.0.113.42')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add it' })).toBeEnabled() + }) + + it.each(['203.0.113.42/32', '::ffff:203.0.113.42/128'])( + 'does not duplicate the current IP already stored as %s', + async (entry) => { + render( + , + ) + expect(await screen.findByRole('button', { name: 'Add it' })).toBeDisabled() + }, + ) + + it('does not append an IP beyond the 100 entry limit', async () => { + render( + `10.0.0.${i}/32`)} + onOpenChange={vi.fn()} + />, + ) + expect(await screen.findByRole('button', { name: 'Add it' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled() + expect(screen.getAllByPlaceholderText('10.0.0.0/8')).toHaveLength(100) + }) + it.each(['create', 'edit'] as const)( 'accepts a mapped IPv6 address in %s mode and submits it to the server', async (mode) => { diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts index c9256cde97a131..1ad5c99d1a9d39 100644 --- a/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/validate-ip-entry.spec.ts @@ -4,6 +4,7 @@ import { collectIpAddresses, IP_POLICY_CIDR_MAX_COUNT, IP_POLICY_NAME_MAX_LENGTH, + isSameIpAddress, toAllowedCidr, validateIpEntry, } from '../validate-ip-entry' @@ -99,6 +100,24 @@ describe('validateIpEntry', () => { }) }) +describe('isSameIpAddress', () => { + it.each([ + ['203.0.113.42/32', '203.0.113.42'], + ['::ffff:203.0.113.42/128', '203.0.113.42'], + ['::ffff:cb00:712a', '203.0.113.42'], + ['2001:0DB8:0:0:0:0:0:1/128', '2001:db8::1'], + ])('recognizes duplicate host entries %s and %s', (entry, ip) => { + expect(isSameIpAddress(entry, ip)).toBe(true) + }) + + it('keeps ranges and distinct addresses separate from host entries', () => { + expect(isSameIpAddress('203.0.113.0/24', '203.0.113.42')).toBe(false) + expect(isSameIpAddress('2001:db8::/32', '2001:db8::1')).toBe(false) + expect(isSameIpAddress('203.0.113.43', '203.0.113.42')).toBe(false) + expect(isSameIpAddress('invalid', '203.0.113.42')).toBe(false) + }) +}) + describe('canSubmitIpPolicy', () => { it('requires a name and at least one valid entry', () => { expect(canSubmitIpPolicy('', ['10.0.0.0/8'])).toBe(false) diff --git a/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx index e712ba6648a583..f6a75f01721491 100644 --- a/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/allowlist-field.tsx @@ -8,15 +8,23 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { Input } from '@langgenius/dify-ui/input' import { useTranslation } from 'react-i18next' import { createAllowlistRow } from './allowlist-row' -import { validateIpEntry } from './validate-ip-entry' +import { IP_POLICY_CIDR_MAX_COUNT, isSameIpAddress, validateIpEntry } from './validate-ip-entry' type AllowlistFieldProps = { entries: AllowlistRow[] currentIp?: string + currentIpStatus?: 'loading' | 'error' + onRetryCurrentIp?: () => void onEntriesChange: (entries: AllowlistRow[]) => void } -export function AllowlistField({ entries, currentIp, onEntriesChange }: AllowlistFieldProps) { +export function AllowlistField({ + entries, + currentIp, + currentIpStatus, + onRetryCurrentIp, + onEntriesChange, +}: AllowlistFieldProps) { const { t } = useTranslation() const errorMessage = (code: IpEntryErrorCode, max?: 32 | 128) => { @@ -54,13 +62,14 @@ export function AllowlistField({ entries, currentIp, onEntriesChange }: Allowlis const addCurrentIp = () => { if (!currentIp) return - if (entries.some((entry) => entry.value.trim() === currentIp)) return + if (entries.some((entry) => isSameIpAddress(entry.value, currentIp))) return const emptyRow = entries.find((entry) => entry.value.trim() === '') if (emptyRow) { updateEntry(emptyRow.id, currentIp) return } - onEntriesChange([...entries, createAllowlistRow(currentIp)]) + if (entries.length < IP_POLICY_CIDR_MAX_COUNT) + onEntriesChange([...entries, createAllowlistRow(currentIp)]) } return ( @@ -119,22 +128,42 @@ export function AllowlistField({ entries, currentIp, onEntriesChange }: Allowlis variant="secondary" size="small" className="shrink-0 self-start" + disabled={entries.length >= IP_POLICY_CIDR_MAX_COUNT} onClick={() => onEntriesChange([...entries, createAllowlistRow()])} > {t(($) => $['settings.ipPolicyAddEntry'], { ns: 'common' })} + {currentIpStatus && ( +

    + {currentIpStatus === 'loading' + ? t(($) => $['settings.ipPolicyCurrentIpLoading'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyCurrentIpError'], { ns: 'common' })} + {currentIpStatus === 'error' && onRetryCurrentIp && ( + + )} +

    + )} + {currentIp && (

    {t(($) => $['settings.ipPolicyCurrentIp'], { ns: 'common', ip: currentIp })} - +

    )} diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx index 77241b49905e55..09c2996a9b512b 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-dialog.tsx @@ -12,8 +12,10 @@ import { import { Field, FieldLabel } from '@langgenius/dify-ui/field' import { IconButton } from '@langgenius/dify-ui/icon-button' import { Input } from '@langgenius/dify-ui/input' +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/console' import { AllowlistField } from './allowlist-field' import { createAllowlistRow } from './allowlist-row' import { PolicyReferencedApps } from './referenced-apps' @@ -54,6 +56,23 @@ export function IpPolicyDialog({ onSubmit, }: IpPolicyDialogProps) { const { t } = useTranslation() + const currentIpQuery = useQuery( + consoleQuery.workspaces.current.networkAccessGroups.currentIp.get.queryOptions({ + enabled: open && mode !== 'view' && currentIp === undefined, + retry: false, + staleTime: 0, + gcTime: 0, + context: { silent: true }, + }), + ) + const resolvedCurrentIp = + currentIp ?? (currentIpQuery.isSuccess ? currentIpQuery.data.client_ip : undefined) + const currentIpStatus = + currentIp !== undefined || currentIpQuery.isSuccess + ? undefined + : currentIpQuery.isError + ? 'error' + : 'loading' const [name, setName] = useState(initialName) const [entries, setEntries] = useState(() => initialEntries && initialEntries.length > 0 @@ -165,7 +184,15 @@ export function IpPolicyDialog({ /> - + { + void currentIpQuery.refetch() + }} + onEntriesChange={setEntries} + /> {mode === 'edit' && usedByCount > 0 && ( diff --git a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts index 6885d52c1a9eaa..3dd3d102dc42e9 100644 --- a/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts +++ b/web/app/components/header/account-setting/ip-policies-page/validate-ip-entry.ts @@ -139,6 +139,26 @@ export function collectAllowedCidrs(entries: readonly string[]) { return collectIpAddresses(entries).map(toAllowedCidr) } +/** Compare host entries, including compressed and IPv4-mapped IPv6, without matching ranges. */ +export function isSameIpAddress(entry: string, ip: string): boolean { + const normalize = (raw: string) => { + const value = raw.trim() + if (validateIpEntry(value).kind !== 'valid') return null + const [address = '', prefix] = value.split('/') + const ipv6 = address.includes(':') + if (prefix !== undefined && Number(prefix) !== (ipv6 ? 128 : 32)) return null + if (!ipv6) return address + const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1) + const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(canonical) + if (!mapped) return canonical + const high = Number.parseInt(mapped[1] ?? '', 16) + const low = Number.parseInt(mapped[2] ?? '', 16) + return [high >> 8, high & 255, low >> 8, low & 255].join('.') + } + const address = normalize(entry) + return address !== null && address === normalize(ip) +} + export function splitPolicySummary(addresses: readonly string[]): { listed: readonly string[] moreCount: number diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index d539b465c50a0c..bf94a78223c695 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "مستخدمة في", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "تعذّر التحقق من عنوان IP الخاص بك. يرجى المحاولة مرة أخرى.", + "settings.ipPolicyCurrentIpLoading": "جارٍ التحقق من عنوان IP الخاص بك…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "ستسمح هذه التطبيقات بالوصول من أي عنوان IP. لا يمكن التراجع عن هذا الإجراء.", "settings.ipPolicyDeleteConfirm": "هل تريد حذف “{{name}}”؟", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 2ee75494d14e2a..c0c620db0e30eb 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Verwendet von", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Ihre IP-Adresse konnte nicht überprüft werden. Bitte versuchen Sie es erneut.", + "settings.ipPolicyCurrentIpLoading": "Ihre IP-Adresse wird überprüft…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Diese Apps erlauben dann den Zugriff von jeder IP-Adresse. Dies kann nicht rückgängig gemacht werden.", "settings.ipPolicyDeleteConfirm": "“{{name}}” löschen?", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 255fc1bc49f227..f252004ea1f622 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Used by", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Unable to check your IP address. Please try again.", + "settings.ipPolicyCurrentIpLoading": "Checking your IP address…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Apps using this policy will become accessible from any IP address. This can’t be undone.", "settings.ipPolicyDeleteConfirm": "Delete “{{name}}”?", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index bd93463e067af6..aca771c90e0681 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Usada por", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "No se pudo comprobar tu dirección IP. Inténtalo de nuevo.", + "settings.ipPolicyCurrentIpLoading": "Comprobando tu dirección IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Estas aplicaciones permitirán el acceso desde cualquier dirección IP. Esta acción no se puede deshacer.", "settings.ipPolicyDeleteConfirm": "¿Eliminar “{{name}}”?", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index d88fba7c3a8d34..3ea55ce31a6c47 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "استفاده‌شده توسط", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "بررسی نشانی IP شما ممکن نیست. لطفاً دوباره تلاش کنید.", + "settings.ipPolicyCurrentIpLoading": "در حال بررسی نشانی IP شما…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "این برنامه‌ها دسترسی از هر نشانی IP را مجاز خواهند کرد. این اقدام قابل بازگشت نیست.", "settings.ipPolicyDeleteConfirm": "آیا “{{name}}” حذف شود؟", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 7a29c35f95e380..9d401e9c3bb9aa 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Utilisée par", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Impossible de vérifier votre adresse IP. Veuillez réessayer.", + "settings.ipPolicyCurrentIpLoading": "Vérification de votre adresse IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Ces applications autoriseront l’accès depuis n’importe quelle adresse IP. Cette action est irréversible.", "settings.ipPolicyDeleteConfirm": "Supprimer “{{name}}” ?", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 70a8b48d929266..9bf45d42b93ef3 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "उपयोगकर्ता ऐप", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "आपके IP पते की जाँच नहीं हो सकी। कृपया फिर से प्रयास करें।", + "settings.ipPolicyCurrentIpLoading": "आपके IP पते की जाँच की जा रही है…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "ये ऐप किसी भी IP पते से एक्सेस की अनुमति देंगे। इस कार्रवाई को पूर्ववत नहीं किया जा सकता।", "settings.ipPolicyDeleteConfirm": "“{{name}}” हटाएं?", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index bf5065d9374d72..e67f831cfc1c5d 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Digunakan oleh", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Alamat IP Anda tidak dapat diperiksa. Coba lagi.", + "settings.ipPolicyCurrentIpLoading": "Memeriksa alamat IP Anda…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Aplikasi ini akan mengizinkan akses dari alamat IP mana pun. Tindakan ini tidak dapat dibatalkan.", "settings.ipPolicyDeleteConfirm": "Hapus “{{name}}”?", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 13534cdeda38ea..da5054c874cd45 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Utilizzata da", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Impossibile verificare il tuo indirizzo IP. Riprova.", + "settings.ipPolicyCurrentIpLoading": "Verifica dell’indirizzo IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Queste app consentiranno l’accesso da qualsiasi indirizzo IP. Questa azione non può essere annullata.", "settings.ipPolicyDeleteConfirm": "Eliminare “{{name}}”?", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index bdd89f69c084b3..e172a372ea4e11 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "使用中のアプリ", "settings.ipPolicyCreate": "作成", "settings.ipPolicyCurrentIp": "現在の IP は {{ip}} です", + "settings.ipPolicyCurrentIpError": "IP アドレスを確認できません。もう一度お試しください。", + "settings.ipPolicyCurrentIpLoading": "IP アドレスを確認しています…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "このポリシーを使用しているアプリには、すべての IP アドレスからアクセスできるようになります。この操作は元に戻せません。", "settings.ipPolicyDeleteConfirm": "「{{name}}」を削除しますか?", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index b0574c9299539f..07d8d1b9917b02 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "사용 중인 앱", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "IP 주소를 확인할 수 없습니다. 다시 시도하세요.", + "settings.ipPolicyCurrentIpLoading": "IP 주소 확인 중…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "이 앱들은 모든 IP 주소에서 접근할 수 있게 됩니다. 이 작업은 되돌릴 수 없습니다.", "settings.ipPolicyDeleteConfirm": "“{{name}}”을(를) 삭제하시겠습니까?", diff --git a/web/i18n/lo-LA/common.json b/web/i18n/lo-LA/common.json index fe18c6e335ea05..5e4fa955821757 100644 --- a/web/i18n/lo-LA/common.json +++ b/web/i18n/lo-LA/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "ນຳໃຊ້ໂດຍ", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "ບໍ່ສາມາດກວດສອບທີ່ຢູ່ IP ຂອງທ່ານໄດ້. ກະລຸນາລອງໃໝ່.", + "settings.ipPolicyCurrentIpLoading": "ກຳລັງກວດສອບທີ່ຢູ່ IP ຂອງທ່ານ…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "ແອັບເຫຼົ່ານີ້ຈະອະນຸຍາດໃຫ້ເຂົ້າເຖິງຈາກທີ່ຢູ່ IP ໃດກໍໄດ້. ການດຳເນີນການນີ້ບໍ່ສາມາດຍົກເລີກໄດ້.", "settings.ipPolicyDeleteConfirm": "ລຶບ “{{name}}” ຫຼືບໍ່?", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 2237db959bc052..da413a49fe3ad9 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Gebruikt door", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Je IP-adres kan niet worden gecontroleerd. Probeer het opnieuw.", + "settings.ipPolicyCurrentIpLoading": "Je IP-adres wordt gecontroleerd…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Deze apps staan dan toegang vanaf elk IP-adres toe. Dit kan niet ongedaan worden gemaakt.", "settings.ipPolicyDeleteConfirm": "“{{name}}” verwijderen?", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 420b500ed0ce2b..f7c2193dde8289 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Używana przez", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Nie można sprawdzić Twojego adresu IP. Spróbuj ponownie.", + "settings.ipPolicyCurrentIpLoading": "Sprawdzanie Twojego adresu IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Te aplikacje będą zezwalać na dostęp z dowolnego adresu IP. Tej operacji nie można cofnąć.", "settings.ipPolicyDeleteConfirm": "Usunąć “{{name}}”?", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index e5935c980aa8fd..cc5a3cbc091f16 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Usada por", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Não foi possível verificar seu endereço IP. Tente novamente.", + "settings.ipPolicyCurrentIpLoading": "Verificando seu endereço IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Esses aplicativos permitirão o acesso de qualquer endereço IP. Esta ação não pode ser desfeita.", "settings.ipPolicyDeleteConfirm": "Excluir “{{name}}”?", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 6695bec7dd988c..2ff4b866aa116e 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Utilizată de", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Adresa IP nu poate fi verificată. Încercați din nou.", + "settings.ipPolicyCurrentIpLoading": "Se verifică adresa IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Aceste aplicații vor permite accesul de la orice adresă IP. Această acțiune nu poate fi anulată.", "settings.ipPolicyDeleteConfirm": "Ștergeți “{{name}}”?", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 83a3d3f4555ac8..cdeec4b3d47d6e 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Используется", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Не удалось проверить ваш IP-адрес. Повторите попытку.", + "settings.ipPolicyCurrentIpLoading": "Проверка вашего IP-адреса…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Эти приложения станут доступны с любого IP-адреса. Это действие нельзя отменить.", "settings.ipPolicyDeleteConfirm": "Удалить “{{name}}”?", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 5c0a5ddde4e2f8..3dd1e6a974a0f5 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Uporabljajo", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Vašega naslova IP ni mogoče preveriti. Poskusite znova.", + "settings.ipPolicyCurrentIpLoading": "Preverjanje vašega naslova IP…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Te aplikacije bodo dovolile dostop s katerega koli naslova IP. Tega dejanja ni mogoče razveljaviti.", "settings.ipPolicyDeleteConfirm": "Izbrišem “{{name}}”?", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 1ccc41c6c01111..c1db7089dd88d5 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "ใช้งานโดย", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "ไม่สามารถตรวจสอบที่อยู่ IP ของคุณได้ โปรดลองอีกครั้ง", + "settings.ipPolicyCurrentIpLoading": "กำลังตรวจสอบที่อยู่ IP ของคุณ…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "แอปเหล่านี้จะอนุญาตให้เข้าถึงจากที่อยู่ IP ใดก็ได้ การดำเนินการนี้ไม่สามารถยกเลิกได้", "settings.ipPolicyDeleteConfirm": "ลบ “{{name}}” หรือไม่?", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 5b1b711cd70416..1c43df0bc8821c 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Kullanan uygulamalar", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "IP adresiniz kontrol edilemiyor. Lütfen tekrar deneyin.", + "settings.ipPolicyCurrentIpLoading": "IP adresiniz kontrol ediliyor…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Bu uygulamalar herhangi bir IP adresinden erişime izin verecek. Bu işlem geri alınamaz.", "settings.ipPolicyDeleteConfirm": "“{{name}}” silinsin mi?", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index d4846344bad5e3..491046ae38e65e 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Використовується", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Не вдалося перевірити вашу IP-адресу. Спробуйте ще раз.", + "settings.ipPolicyCurrentIpLoading": "Перевірка вашої IP-адреси…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Ці застосунки дозволятимуть доступ із будь-якої IP-адреси. Цю дію неможливо скасувати.", "settings.ipPolicyDeleteConfirm": "Видалити “{{name}}”?", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 8dac2c4e41bc26..7582ba5c113c21 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "Được sử dụng bởi", "settings.ipPolicyCreate": "Create", "settings.ipPolicyCurrentIp": "Your current IP is {{ip}}", + "settings.ipPolicyCurrentIpError": "Không thể kiểm tra địa chỉ IP của bạn. Vui lòng thử lại.", + "settings.ipPolicyCurrentIpLoading": "Đang kiểm tra địa chỉ IP của bạn…", "settings.ipPolicyDeleteBound": "This policy is in use", "settings.ipPolicyDeleteBoundDescription": "Các ứng dụng này sẽ cho phép truy cập từ mọi địa chỉ IP. Không thể hoàn tác thao tác này.", "settings.ipPolicyDeleteConfirm": "Xóa “{{name}}”?", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index f3ba706da4498a..8c0cd1355982a5 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "生效应用", "settings.ipPolicyCreate": "创建", "settings.ipPolicyCurrentIp": "你当前的 IP 是 {{ip}}", + "settings.ipPolicyCurrentIpError": "无法检查您的 IP 地址,请重试。", + "settings.ipPolicyCurrentIpLoading": "正在检查您的 IP 地址…", "settings.ipPolicyDeleteBound": "该策略正在使用中", "settings.ipPolicyDeleteBoundDescription": "正在引用此策略的应用将完全开放访问。此操作无法撤销。", "settings.ipPolicyDeleteConfirm": "删除“{{name}}”?", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 24ceadb93c832d..f25cfef572b4b0 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -570,6 +570,8 @@ "settings.ipPolicyColumnUsedBy": "使用中的應用程式", "settings.ipPolicyCreate": "建立", "settings.ipPolicyCurrentIp": "你目前的 IP 是 {{ip}}", + "settings.ipPolicyCurrentIpError": "無法檢查您的 IP 位址,請重試。", + "settings.ipPolicyCurrentIpLoading": "正在檢查您的 IP 位址…", "settings.ipPolicyDeleteBound": "該策略正在使用中", "settings.ipPolicyDeleteBoundDescription": "這些應用程式將允許從任何 IP 位址存取。此操作無法復原。", "settings.ipPolicyDeleteConfirm": "確定刪除 “{{name}}”?", From c5a9e9bb0cb148cd5dc1a6f0d22f7fd3b4c196f9 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:30:01 +0800 Subject: [PATCH 048/108] fix(web): check current IP before saving access control (SNP-826) --- .../app/access-point/__tests__/index.spec.tsx | 46 +++- .../__tests__/config-panel.spec.tsx | 11 +- .../access-control/__tests__/draft.spec.ts | 16 +- .../access-control/__tests__/index.spec.tsx | 237 ++++++++++++++++-- .../access-control/config-panel.tsx | 26 +- .../app/access-point/access-control/index.tsx | 174 +++++++++++-- .../access-control/policy-field.tsx | 35 ++- web/app/components/app/access-point/index.tsx | 6 + .../ip-policies-page/validate-ip-entry.ts | 36 --- .../access/__tests__/page.spec.tsx | 27 +- .../agent-v2/agent-detail/access/page.tsx | 1 + web/i18n/ar-TN/deployments.json | 1 + web/i18n/de-DE/deployments.json | 1 + web/i18n/en-US/deployments.json | 1 + web/i18n/es-ES/deployments.json | 1 + web/i18n/fa-IR/deployments.json | 1 + web/i18n/fr-FR/deployments.json | 1 + web/i18n/hi-IN/deployments.json | 1 + web/i18n/id-ID/deployments.json | 1 + web/i18n/it-IT/deployments.json | 1 + web/i18n/ja-JP/deployments.json | 1 + web/i18n/ko-KR/deployments.json | 1 + web/i18n/lo-LA/deployments.json | 1 + web/i18n/nl-NL/deployments.json | 1 + web/i18n/pl-PL/deployments.json | 1 + web/i18n/pt-BR/deployments.json | 1 + web/i18n/ro-RO/deployments.json | 1 + web/i18n/ru-RU/deployments.json | 1 + web/i18n/sl-SI/deployments.json | 1 + web/i18n/th-TH/deployments.json | 1 + web/i18n/tr-TR/deployments.json | 1 + web/i18n/uk-UA/deployments.json | 1 + web/i18n/vi-VN/deployments.json | 1 + web/i18n/zh-Hans/deployments.json | 1 + web/i18n/zh-Hant/deployments.json | 1 + 35 files changed, 523 insertions(+), 116 deletions(-) diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index c2fdb7b9c9b461..af13c684b810d9 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -8,6 +8,11 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { + createNetworkAccessGroupFixture, + seedAppNetworkAccessGroup, + seedNetworkAccessGroups, +} from '@/test/console/network-access' import { seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { render } from '@/test/console/render' @@ -16,6 +21,7 @@ import { AppACLPermission } from '@/utils/permission' import AccessPoint from '..' let appMode = 'workflow' +let appPublished = false let appPermissionKeys: string[] = [AppACLPermission.AccessPointView] const accessPointMocks = vi.hoisted(() => ({ builtIn: vi.fn(), @@ -28,7 +34,10 @@ const mockConsoleState = vi.hoisted(() => ({ vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') + const { default: translations } = await import('@/i18n/en-US/deployments.json') return createReactI18nextMock({ + ...translations, + 'operation.save': 'Save', 'workflow.nodes.common.memories.builtIn': 'Built-in', }) }) @@ -39,6 +48,8 @@ vi.mock('@/app/components/app/store', () => ({ appDetail: { id: 'app-1', mode: appMode, + workflow: appPublished ? { id: 'published-workflow' } : null, + model_config: appPublished ? { id: 'published-config' } : null, maintainer: 'user-2', permission_keys: appPermissionKeys, }, @@ -103,14 +114,20 @@ const appEnvironments: AppEnvironment[] = [ const renderAccessPoint = ({ environments = appEnvironments, searchParams = '', + cloud = false, }: { environments?: AppEnvironment[] searchParams?: string + cloud?: boolean } = {}) => { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) seedCurrentWorkspaceQuery(queryClient) - seedSystemFeatures(queryClient) + seedSystemFeatures(queryClient, { deployment_edition: cloud ? 'CLOUD' : 'COMMUNITY' }) + if (cloud) { + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + seedAppNetworkAccessGroup(queryClient, 'app-1') + } const queryOptions = consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ input: { @@ -140,6 +157,7 @@ describe('AccessPoint', () => { beforeEach(() => { vi.clearAllMocks() appMode = 'workflow' + appPublished = false appPermissionKeys = [AppACLPermission.AccessPointView] }) @@ -307,4 +325,30 @@ describe('AccessPoint', () => { }), ) }) + + it.each(['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion'])( + 'checks published %s apps before applying a policy that excludes the current IP', + async (mode) => { + const user = userEvent.setup() + appMode = mode + appPublished = true + appPermissionKeys = [AppACLPermission.AccessPointManage] + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ allowed: false, client_ip: '203.0.113.42', policy_version: 1 }), + ) + renderAccessPoint({ cloud: true }) + await user.click(screen.getByRole('button', { name: /Access Control/ })) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect( + await screen.findByRole('alertdialog', { name: 'Save without your own IP?' }), + ).toBeInTheDocument() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.every(([input, init]) => new Request(input, init).method === 'GET'), + ).toBe(true) + }, + ) }) diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index e17b601a4753cc..ba8a353c933e85 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -1,3 +1,4 @@ +import type { NetworkAccessGroupCurrentIpCheckResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessControlDraft, AccessControlPolicy } from '../draft' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { screen } from '@testing-library/react' @@ -38,7 +39,7 @@ vi.mock('react-i18next', async () => { }) function PanelHarness({ - currentIp, + ipCheck, initialDraft = createDefaultAccessControlDraft(ACCESS_POINT_ORDER), onSave = vi.fn(), onCreatePolicy = vi.fn(), @@ -46,7 +47,7 @@ function PanelHarness({ canManagePolicies = true, readOnly = false, }: { - currentIp?: string + ipCheck?: NetworkAccessGroupCurrentIpCheckResponse initialDraft?: AccessControlDraft onSave?: () => void onCreatePolicy?: () => void @@ -68,7 +69,7 @@ function PanelHarness({ canManagePolicies={canManagePolicies} readOnly={readOnly} policies={policies} - currentIp={currentIp} + ipCheck={ipCheck} onCancel={onCancel} onCreatePolicy={onCreatePolicy} onManagePolicies={onManagePolicies} @@ -198,7 +199,9 @@ describe('AccessControlConfigPanel', () => { it('shows a lockout warning when the current IP is outside the selected policy', async () => { const user = userEvent.setup() - render() + render( + , + ) await user.click(await screen.findByRole('option', { name: /Office VPN/ })) diff --git a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts index 62628686c96404..0657069025247e 100644 --- a/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts +++ b/web/app/components/app/access-point/access-control/__tests__/draft.spec.ts @@ -1,8 +1,5 @@ import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' -import { - policyIncludesIp, - splitPolicySummary, -} from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' +import { splitPolicySummary } from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' import { canSaveAccessControl, createDefaultAccessControlDraft, @@ -122,17 +119,6 @@ describe('splitPolicySummary', () => { }) }) -describe('policyIncludesIp', () => { - it('matches an exact address and a covering CIDR', () => { - expect(policyIncludesIp(['203.0.113.42', '198.51.100.0/24'], '203.0.113.42')).toBe(true) - expect(policyIncludesIp(['198.51.100.0/24'], '198.51.100.20')).toBe(true) - }) - - it('does not match an address outside the policy', () => { - expect(policyIncludesIp(['198.51.100.0/24'], '203.0.113.42')).toBe(false) - }) -}) - describe('available access points', () => { it.each([ { available: ['webApp', 'serviceApi', 'mcp', 'trigger'] as const, selected: 4 }, diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 661b99953a4e04..92f4bbc38c52b1 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -58,7 +58,12 @@ vi.mock('nuqs', async (importOriginal) => { vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') const { default: deploymentTranslations } = await import('@/i18n/en-US/deployments.json') - return createReactI18nextMock({ ...accessControlTranslations, ...deploymentTranslations }) + const { default: commonTranslations } = await import('@/i18n/en-US/common.json') + return createReactI18nextMock({ + ...commonTranslations, + ...accessControlTranslations, + ...deploymentTranslations, + }) }) const renderEntry = ({ @@ -69,6 +74,7 @@ const renderEntry = ({ availableAccessPoints = ['webapp', 'service_api', 'mcp'], role = 'owner', canEditBinding = true, + isPublished = false, }: { plan?: CloudPlan deploymentEdition?: 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' @@ -77,6 +83,7 @@ const renderEntry = ({ availableAccessPoints?: AppNetworkAccessGroupResponse['available_access_points'] role?: GetWorkspacesCurrentSummaryResponse['role'] canEditBinding?: boolean + isPublished?: boolean } = {}) => { const queryClient = createConsoleQueryClient() const entitled = plan === 'professional' || plan === 'team' @@ -88,7 +95,12 @@ const renderEntry = ({ }) return renderWithConsoleQuery( - , + , { queryClient, currentWorkspace: { role }, @@ -105,6 +117,8 @@ const renderEntry = ({ } const metadataResponse = (request: Request) => { + if (request.url.endsWith('/check-current-ip')) + return Response.json({ allowed: true, client_ip: '203.0.113.42', policy_version: 1 }) if (request.url.endsWith('/network-access-groups/current-ip')) return Response.json({ client_ip: '203.0.113.42' }) } @@ -484,7 +498,7 @@ describe('AccessControlEntry', () => { expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() expect(screen.queryByRole('switch', { name: 'Trigger' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) expect(within(getChip()).getByText('ON')).toBeInTheDocument() }) @@ -593,7 +607,6 @@ describe('AccessControlEntry', () => { expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() }) - it('reopens the edit layer after dismissing a paused draft with Escape', async () => { const user = userEvent.setup() renderEntry({ @@ -674,6 +687,8 @@ describe('supported access points and binding permission', () => { const requests: Record[] = [] const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const request = new Request(input, init) + const metadata = metadataResponse(request) + if (metadata) return metadata if (request.method === 'PUT') { const payload = await request.json() requests.push(payload) @@ -760,12 +775,14 @@ describe('supported access points and binding permission', () => { await user.click(screen.getByRole('button', { name: 'Manage IP policies' })) expect(mockSetSettingsDestination).toHaveBeenCalledWith('ip-policies') unmount() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - Response.json({ - binding: createBinding(), - available_access_points: ['webapp', 'service_api', 'mcp'], - effective_enabled: true, - }), + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input, init) => + metadataResponse(new Request(input, init)) ?? + Response.json({ + binding: createBinding(), + available_access_points: ['webapp', 'service_api', 'mcp'], + effective_enabled: true, + }), ) try { renderEntry({ @@ -776,8 +793,14 @@ describe('supported access points and binding permission', () => { await user.click(getChip()) await user.click(screen.getByRole('option', { name: /Internal Network/ })) await user.click(screen.getByRole('button', { name: 'Save' })) - await waitFor(() => expect(fetchSpy).toHaveBeenCalled()) - const [input, init] = fetchSpy.mock.calls[0]! + await waitFor(() => + expect( + fetchSpy.mock.calls.some(([input, init]) => new Request(input, init).method === 'PUT'), + ).toBe(true), + ) + const [input, init] = fetchSpy.mock.calls.find( + ([input, init]) => new Request(input, init).method === 'PUT', + )! expect(new Request(input, init).method).toBe('PUT') } finally { fetchSpy.mockRestore() @@ -901,9 +924,11 @@ describe('supported access points and binding permission', () => { await user.click(screen.getByRole('combobox', { name: 'IP Policy' })) await user.click(screen.getByRole('option', { name: /Office/ })) await user.click(screen.getByRole('switch', { name: 'MCP Server' })) - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) - rendered.rerender() + rendered.rerender( + , + ) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) await user.click(getChip()) expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() @@ -915,7 +940,9 @@ describe('supported access points and binding permission', () => { 'true', ) - rendered.rerender() + rendered.rerender( + , + ) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) await user.click(getChip()) expect(screen.getByText('Restricted to Internal Network')).toBeInTheDocument() @@ -938,13 +965,17 @@ describe('supported access points and binding permission', () => { await user.click(screen.getByRole('switch', { name: 'Enable access control' })) expect(screen.getByRole('alertdialog')).toBeInTheDocument() - rendered.rerender() + rendered.rerender( + , + ) await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) await user.click(getChip()) expect(screen.getByRole('switch', { name: 'Enable access control' })).toBeChecked() expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() - rendered.rerender() + rendered.rerender( + , + ) await user.click(getChip()) expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() expect(screen.getByRole('switch', { name: 'Enable access control' })).toBeChecked() @@ -986,8 +1017,10 @@ describe('supported access points and binding permission', () => { await user.click(getChip()) await user.click(screen.getByRole('option', { name: /Internal Network/ })) await user.click(screen.getByRole('switch', { name: 'Web App' })) - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - rendered.rerender() + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + rendered.rerender( + , + ) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() await user.click(getChip()) expect(screen.getByRole('switch', { name: 'Web App' })).toBeChecked() @@ -1018,10 +1051,13 @@ describe('supported access points and binding permission', () => { seedNetworkAccessGroups(queryClient, { entitled: true }) const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => response()) try { - renderWithConsoleQuery(, { - queryClient, - systemFeatures: { deployment_edition: 'CLOUD' }, - }) + renderWithConsoleQuery( + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) await waitFor(() => expect( queryClient.getQueryState( @@ -1038,3 +1074,158 @@ describe('supported access points and binding permission', () => { } }) }) + +const setupBindingServer = (initialBinding: AppNetworkAccessGroupBindingResponse | null = null) => { + const state = { + binding: initialBinding, + groups: [createNetworkAccessGroupFixture()], + check: { allowed: false, client_ip: '203.0.113.42', policy_version: 1 }, + checkStatus: 200, + checks: 0, + writes: [] as Record[], + put: undefined as (() => Promise) | undefined, + createStatus: 201, + } + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url.endsWith('/check-current-ip')) { + state.checks += 1 + return Response.json( + state.checkStatus === 200 ? state.check : { message: 'IP unavailable' }, + { status: state.checkStatus }, + ) + } + if (request.url.endsWith('/current-ip')) + return Response.json({ client_ip: state.check.client_ip }) + if (request.method === 'PUT') { + const payload = await request.json() + state.writes.push(payload) + if (state.put) return state.put() + state.binding = createBinding({ ...payload, version: (state.binding?.version ?? 0) + 1 }) + return Response.json({ binding: state.binding, effective_enabled: state.binding.enabled }) + } + if (request.url.endsWith('/network-access-groups')) { + if (request.method === 'POST') { + if (state.createStatus !== 201) + return Response.json({ message: 'Creation failed' }, { status: state.createStatus }) + const group = createNetworkAccessGroupFixture({ + ...(await request.json()), + id: 'new-policy', + }) + state.groups.push(group) + return Response.json({ group }, { status: 201 }) + } + return Response.json({ tenant_id: 'workspace-1', entitled: true, groups: state.groups }) + } + return Response.json({ + tenant_id: 'workspace-1', + app_id: 'app-1', + entitled: true, + binding: state.binding, + effective_enabled: state.binding?.enabled ?? false, + available_access_points: ['webapp', 'service_api', 'mcp'], + }) + }) + return state +} + +const openSelectedConfig = async (user: ReturnType) => { + await user.click(getChip()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) +} + +const lockoutWarning = "Your IP (203.0.113.42) isn't in this policy. You may lose access." + +describe('trusted IP checks before saving', () => { + it.each([true, false])( + 'uses the server match result and only confirms a published denial (allowed: %s)', + async (allowed) => { + const user = userEvent.setup() + const server = setupBindingServer() + server.check.allowed = allowed + renderEntry({ plan: 'professional', groups: server.groups, isPublished: true }) + await openSelectedConfig(user) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + expect(Boolean(screen.queryByText(lockoutWarning))).toBe(!allowed) + await user.click(screen.getByRole('button', { name: 'Save' })) + if (allowed) { + await waitFor(() => expect(server.writes).toHaveLength(1)) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + } else { + const dialog = await screen.findByRole('alertdialog', { name: 'Save without your own IP?' }) + expect(within(dialog).getByText(lockoutWarning)).toBeInTheDocument() + expect(server.writes).toHaveLength(0) + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + expect(await screen.findByRole('button', { name: 'Save' })).toBeInTheDocument() + expect(server.writes).toHaveLength(0) + await user.click(screen.getByRole('button', { name: 'Save' })) + await user.click(await screen.findByRole('button', { name: 'Save anyway' })) + await waitFor(() => expect(server.writes).toHaveLength(1)) + } + }, + ) + + it('warns but saves an unpublished draft without confirmation', async () => { + const user = userEvent.setup() + const server = setupBindingServer() + renderEntry({ plan: 'professional', groups: server.groups }) + await openSelectedConfig(user) + expect(await screen.findByText(lockoutWarning)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(server.writes).toHaveLength(1)) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(server.checks).toBeGreaterThanOrEqual(2) + }) + + it('requires renewed confirmation when the policy changes before Save anyway', async () => { + const user = userEvent.setup() + const server = setupBindingServer() + renderEntry({ plan: 'professional', groups: server.groups, isPublished: true }) + await openSelectedConfig(user) + await screen.findByText(lockoutWarning) + await user.click(screen.getByRole('button', { name: 'Save' })) + await screen.findByRole('alertdialog') + server.check.policy_version = 2 + await user.click(screen.getByRole('button', { name: 'Save anyway' })) + expect( + await screen.findByText('This policy changed. Review the warning before saving again.'), + ).toBeInTheDocument() + expect(server.writes).toHaveLength(0) + await user.click(screen.getByRole('button', { name: 'Save anyway' })) + await waitFor(() => expect(server.writes).toHaveLength(1)) + }) + + it('blocks saving on an unavailable IP check and recovers through Retry', async () => { + const user = userEvent.setup() + const server = setupBindingServer() + server.checkStatus = 503 + renderEntry({ plan: 'professional', groups: server.groups, isPublished: true }) + await openSelectedConfig(user) + expect( + await screen.findByText('Unable to check your IP address. Please try again.'), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(server.writes).toHaveLength(0) + server.checkStatus = 200 + server.check.allowed = true + await user.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(server.writes).toHaveLength(1)) + }) + + it('does not write when the fresh save check fails after a successful initial check', async () => { + const user = userEvent.setup() + const server = setupBindingServer() + server.check.allowed = true + renderEntry({ plan: 'professional', groups: server.groups, isPublished: true }) + await openSelectedConfig(user) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + server.checkStatus = 503 + await user.click(screen.getByRole('button', { name: 'Save' })) + expect( + await screen.findByText('Unable to check your IP address. Please try again.'), + ).toBeInTheDocument() + expect(server.writes).toHaveLength(0) + }) +}) diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index a22cdcc458691f..a4a7f6f5627cf9 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -1,5 +1,6 @@ 'use client' +import type { NetworkAccessGroupCurrentIpCheckResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { FormEvent } from 'react' import type { AccessControlDraft, AccessControlPolicy } from './draft' import type { AccessControlAppIcon } from './index' @@ -18,7 +19,9 @@ import { AccessControlScopeList } from './scope-list' type AccessControlConfigPanelProps = { draft: AccessControlDraft policies: readonly AccessControlPolicy[] - currentIp?: string + ipCheck?: NetworkAccessGroupCurrentIpCheckResponse + ipCheckStatus?: 'loading' | 'error' + onRetryIpCheck?: () => void availableAccessPoints: readonly AccessPoint[] appIcon: AccessControlAppIcon readOnly?: boolean @@ -37,7 +40,9 @@ type AccessControlConfigPanelProps = { export function AccessControlConfigPanel({ draft, policies, - currentIp, + ipCheck, + ipCheckStatus, + onRetryIpCheck, availableAccessPoints, appIcon, readOnly = false, @@ -60,7 +65,12 @@ export function AccessControlConfigPanel({ const hasPersistableSelection = hasSelectedAccessPoint(draft, availableAccessPoints) const showPolicyError = draft.enabled && !hasSelectedPolicy const showAccessPointError = draft.enabled && !hasPersistableSelection - const canSave = !readOnly && canSaveAccessControl({ draft, baseline, availableAccessPoints }) + const canSave = + !readOnly && + !saving && + ipCheckStatus === undefined && + hasSelectedPolicy && + canSaveAccessControl({ draft, baseline, availableAccessPoints }) const handleSubmit = (event: FormEvent) => { event.preventDefault() @@ -114,8 +124,10 @@ export function AccessControlConfigPanel({ {showAccessPointError && ( @@ -162,7 +174,7 @@ export function AccessControlConfigPanel({ {t(($) => $['operation.cancel'], { ns: 'common' })} {!readOnly && ( - )} diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index a3ead66883ae32..0c901b9566bc7f 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -4,12 +4,21 @@ import type { ComponentProps } from 'react' import type { AccessControlAssignment } from './chip-status' import type { AccessControlDraft } from './draft' import type AppIcon from '@/app/components/base/app-icon' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { pricingQueryParamName, @@ -48,6 +57,7 @@ type AccessControlEntryProps = { appId: string appIcon: AccessControlAppIcon canEditBinding: boolean + isPublished: boolean } type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void @@ -56,7 +66,12 @@ export function AccessControlEntry(props: AccessControlEntryProps) { return } -function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlEntryProps) { +function AccessControlSession({ + appId, + appIcon, + canEditBinding, + isPublished, +}: AccessControlEntryProps) { const { t } = useTranslation() const queryClient = useQueryClient() const canRead = useAtomValue(canReadNetworkAccessAtom) @@ -92,6 +107,31 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE const [showBack, setShowBack] = useState(false) const [draft, setDraft] = useState(null) const [createPolicyOpen, setCreatePolicyOpen] = useState(false) + const [confirmation, setConfirmation] = useState<{ + draft: AccessControlDraft + clientIp: string + policyVersion: number + changed: boolean + } | null>(null) + const saveAttemptRef = useRef(0) + const selectedPolicyId = draft ? draft.selectedPolicyId : bindingQuery.data?.binding?.group_id + const checkCurrentIp = + consoleQuery.workspaces.current.networkAccessGroups.byGroupId.checkCurrentIp.get + const ipCheck = useQuery( + checkCurrentIp.queryOptions({ + input: selectedPolicyId ? { params: { group_id: selectedPolicyId } } : skipToken, + enabled: + canFetchNetworkAccess && + canEditBinding && + bindingQuery.data?.entitled === true && + open && + view === 'config', + context: { silent: true }, + staleTime: 0, + retry: false, + }), + ) + const saveCheck = useMutation(checkCurrentIp.mutationOptions({ context: { silent: true } })) const bindingErrorStatus = getNetworkAccessErrorStatus(bindingQuery.error) const hideForUnsupportedApp = @@ -129,10 +169,6 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE } : null const dirty = !isAccessControlDraftEqual(resolvedDraft, baseline, availableAccessPoints) - const persistableAccessPoints = accessPointsFromScopes( - resolvedDraft.scopes, - availableAccessPoints, - ) const canSave = canSaveAccessControl({ draft: resolvedDraft, baseline, @@ -170,6 +206,9 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE } const discardDraft = () => { + saveAttemptRef.current += 1 + saveCheck.reset() + setConfirmation(null) setDraft(null) setShowBack(false) } @@ -246,18 +285,59 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE ) } - const handleSave = () => { - if (!canMutate || !canSave || !resolvedDraft.selectedPolicyId) return - + const saveDraft = (nextDraft: AccessControlDraft) => { persistBinding( { - enabled: resolvedDraft.enabled, - groupId: resolvedDraft.selectedPolicyId, - accessPoints: persistableAccessPoints, + enabled: nextDraft.enabled, + groupId: nextDraft.selectedPolicyId, + accessPoints: accessPointsFromScopes(nextDraft.scopes, availableAccessPoints), }, () => { discardDraft() setView('status') + setOpen(true) + }, + ) + } + + const handleSave = (acceptedVersion?: number) => { + const nextDraft = confirmation?.draft ?? resolvedDraft + if ( + !canMutate || + !canSave || + !nextDraft.selectedPolicyId || + saveCheck.isPending || + updateBinding.isPending + ) + return + if (!nextDraft.enabled) { + saveDraft(nextDraft) + return + } + const groupId = nextDraft.selectedPolicyId + const attempt = ++saveAttemptRef.current + saveCheck.mutate( + { params: { group_id: groupId } }, + { + onSuccess: (check) => { + if (saveAttemptRef.current !== attempt) return + queryClient.setQueryData( + checkCurrentIp.queryKey({ input: { params: { group_id: groupId } } }), + check, + ) + if (isPublished && !check.allowed && acceptedVersion !== check.policy_version) { + setConfirmation({ + draft: nextDraft, + clientIp: check.client_ip, + policyVersion: check.policy_version, + changed: acceptedVersion !== undefined, + }) + setOpen(false) + return + } + setConfirmation(null) + saveDraft(nextDraft) + }, }, ) } @@ -275,6 +355,8 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE onOpenChange={(nextOpen) => { setOpen(nextOpen) if (!nextOpen) { + saveAttemptRef.current += 1 + saveCheck.reset() if (!dirty) discardDraft() return } @@ -341,18 +423,34 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE availableAccessPoints={availableAccessPoints} policies={policies} readOnly={!canMutate} + ipCheck={ipCheck.isSuccess && !ipCheck.isFetching ? ipCheck.data : undefined} + ipCheckStatus={ + saveCheck.isPending || ipCheck.isFetching + ? 'loading' + : saveCheck.isError || ipCheck.isError + ? 'error' + : undefined + } + onRetryIpCheck={() => { + saveCheck.reset() + void ipCheck.refetch() + }} canManagePolicies={canManagePolicies} baseline={baseline} showBack={showBack} - saving={updateBinding.isPending} + saving={updateBinding.isPending || saveCheck.isPending} onBack={handleBack} onCancel={handleCancel} onCreatePolicy={handleCreatePolicy} onManagePolicies={handleManagePolicies} onDraftChange={(nextDraft) => { - if (canMutate) setDraft(nextDraft) + if (canMutate) { + saveAttemptRef.current += 1 + saveCheck.reset() + setDraft(nextDraft) + } }} - onSave={handleSave} + onSave={() => handleSave()} /> )} @@ -401,6 +499,50 @@ function AccessControlSession({ appId, appIcon, canEditBinding }: AccessControlE }} /> )} + { + if (nextOpen) return + saveAttemptRef.current += 1 + saveCheck.reset() + setConfirmation(null) + setOpen(true) + }} + > + + + {t(($) => $['studio.accessControl.saveWithoutOwnIp'], { ns: 'deployments' })} + + + {t(($) => $['studio.accessControl.lockoutWarning'], { + ns: 'deployments', + ip: confirmation?.clientIp, + })} + + {confirmation?.changed && ( +

    + {t(($) => $['studio.accessControl.policyChanged'], { ns: 'deployments' })} +

    + )} + {saveCheck.isError && ( +

    + {t(($) => $['settings.ipPolicyCurrentIpError'], { ns: 'common' })} +

    + )} + + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + handleSave(confirmation?.policyVersion)} + > + {t(($) => $['studio.accessControl.saveAnyway'], { ns: 'deployments' })} + + +
    +
    ) } diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx index 6805384df2c3f8..fafb5cd89a318b 100644 --- a/web/app/components/app/access-point/access-control/policy-field.tsx +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -1,5 +1,6 @@ 'use client' +import type { NetworkAccessGroupCurrentIpCheckResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { AccessControlPolicy } from './draft' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -16,17 +17,16 @@ import { } from '@langgenius/dify-ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' -import { - policyIncludesIp, - splitPolicySummary, -} from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' +import { splitPolicySummary } from '@/app/components/header/account-setting/ip-policies-page/validate-ip-entry' const CREATE_POLICY_VALUE = '__create-ip-policy__' type AccessControlPolicyFieldProps = { policies: readonly AccessControlPolicy[] selectedPolicyId: string | null - currentIp?: string + ipCheck?: NetworkAccessGroupCurrentIpCheckResponse + ipCheckStatus?: 'loading' | 'error' + onRetryIpCheck?: () => void readOnly?: boolean canManagePolicies: boolean onCreatePolicy: () => void @@ -37,7 +37,9 @@ type AccessControlPolicyFieldProps = { export function AccessControlPolicyField({ policies, selectedPolicyId, - currentIp, + ipCheck, + ipCheckStatus, + onRetryIpCheck, readOnly = false, canManagePolicies, onCreatePolicy, @@ -47,11 +49,6 @@ export function AccessControlPolicyField({ const { t } = useTranslation() const selectedPolicy = policies.find((policy) => policy.id === selectedPolicyId) const summary = selectedPolicy ? splitPolicySummary(selectedPolicy.allowed_cidrs) : undefined - const showLockout = - Boolean(selectedPolicy && currentIp) && - selectedPolicy !== undefined && - currentIp !== undefined && - !policyIncludesIp(selectedPolicy.allowed_cidrs, currentIp) const selectLabel = t(($) => $['studio.accessControl.ipPolicy'], { ns: 'deployments' }) const placeholder = t(($) => $['studio.accessControl.selectPolicy'], { ns: 'deployments' }) const canCreatePolicy = canManagePolicies && !readOnly @@ -225,11 +222,23 @@ export function AccessControlPolicyField({ })}

    )} - {showLockout && currentIp && ( + {ipCheckStatus && ( +

    + {ipCheckStatus === 'loading' + ? t(($) => $['settings.ipPolicyCurrentIpLoading'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyCurrentIpError'], { ns: 'common' })} + {ipCheckStatus === 'error' && onRetryIpCheck && ( + + )} +

    + )} + {!ipCheckStatus && ipCheck && !ipCheck.allowed && (

    {t(($) => $['studio.accessControl.lockoutWarning'], { ns: 'deployments', - ip: currentIp, + ip: ipCheck.client_ip, })}

    )} diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index c8a89aa4f881d9..9da81793a9999c 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -78,6 +78,12 @@ function AccessPointContent({ 255) return null - value = (value << 8) + octet - } - - return value >>> 0 -} - -function ipv4InCidr(ip: string, network: string, prefix: number): boolean { - const ipInt = ipv4ToInt(ip) - const networkInt = ipv4ToInt(network) - if (ipInt === null || networkInt === null) return false - if (prefix === 0) return true - const mask = prefix === 32 ? 0xffffffff : ~((1 << (32 - prefix)) - 1) >>> 0 - return (ipInt & mask) === (networkInt & mask) -} - -export function policyIncludesIp(addresses: readonly string[], ip: string): boolean { - return addresses.some((entry) => { - const cidr = toAllowedCidr(entry) - const slash = cidr.indexOf('/') - const network = slash === -1 ? cidr : cidr.slice(0, slash) - const prefix = slash === -1 ? (cidr.includes(':') ? 128 : 32) : Number(cidr.slice(slash + 1)) - if (network.includes(':')) return cidr === ip || network === ip - if (entry === ip || network === ip) return true - if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false - return ipv4InCidr(ip, network, prefix) - }) -} diff --git a/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx index c7ff3bb316eaed..16a81aac19916a 100644 --- a/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx @@ -24,7 +24,9 @@ vi.mock('../components/workflow-references-table', () => ({ WorkflowReferencesTa vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') + const { default: translations } = await import('@/i18n/en-US/deployments.json') return createReactI18nextMock({ + ...translations, 'studio.accessControl.entryLabel': 'Access Control', 'studio.accessControl.chipOff': 'Off', 'studio.accessControl.chipOn': 'ON', @@ -105,6 +107,8 @@ describe('Agent IP access configuration', () => { const writes: Array<{ url: string; body: unknown }> = [] vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(String(input), init) + if (request.url.endsWith('/check-current-ip')) + return Response.json({ allowed: true, client_ip: '203.0.113.42', policy_version: 1 }) if (request.method === 'PUT') { const body = await request.json() writes.push({ url: request.url, body }) @@ -120,6 +124,7 @@ describe('Agent IP access configuration', () => { expect(screen.queryByRole('switch', { name: 'MCP Server' })).not.toBeInTheDocument() expect(screen.queryByRole('switch', { name: 'Trigger' })).not.toBeInTheDocument() await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => @@ -155,7 +160,7 @@ describe('Agent IP access configuration', () => { expect(screen.getByText('Protecting all 2 access points.')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() - const toggle = screen.getByRole('switch', { name: 'Restrict by IP address' }) + const toggle = screen.getByRole('switch', { name: 'Enable access control' }) expect(toggle).toHaveAttribute('aria-disabled', 'true') await user.click(toggle) expect(toggle).toBeChecked() @@ -189,4 +194,24 @@ describe('Agent IP access configuration', () => { expect(screen.getByRole('switch', { name: 'Backend Service API' })).toBeChecked() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() }) + + it('requires lockout confirmation for an Agent with a published snapshot', async () => { + const user = userEvent.setup() + setup({ access_ready: true }) + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ allowed: false, client_ip: '203.0.113.42', policy_version: 1 }), + ) + await user.click(screen.getByRole('button', { name: /Access Control/ })) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect( + await screen.findByRole('alertdialog', { name: 'Save without your own IP?' }), + ).toBeInTheDocument() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.every(([input, init]) => new Request(input, init).method === 'GET'), + ).toBe(true) + }) }) diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index 0de9770a2ab4e6..ab7e9467e5c9da 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -60,6 +60,7 @@ export function AgentAccessPage({ agentId }: AgentAccessPageProps) { {agent?.app_id && !agent.hidden_app_backed && capabilities.canViewAccessPoint && ( Date: Sun, 20 Sep 2026 17:30:25 +0800 Subject: [PATCH 049/108] fix(web): disable scopes until a valid IP policy is selected (SNP-831) --- .../__tests__/config-panel.spec.tsx | 42 ++++++++++++++----- .../access-control/__tests__/index.spec.tsx | 27 ++++++------ .../access-control/config-panel.tsx | 4 +- 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx index ba8a353c933e85..ad59d816b7aa75 100644 --- a/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/config-panel.spec.tsx @@ -90,22 +90,16 @@ describe('AccessControlConfigPanel', () => { 'Please select an IP policy.', ) expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() - expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toHaveAttribute( + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('switch', { name: 'Backend Service API' })).toHaveAttribute( 'aria-disabled', 'true', ) - expect(screen.getByRole('switch', { name: 'Trigger' })).not.toHaveAttribute( + expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( 'aria-disabled', 'true', ) + expect(screen.getByRole('switch', { name: 'Trigger' })).toHaveAttribute('aria-disabled', 'true') }) it('enables save after a policy is selected', async () => { @@ -230,3 +224,31 @@ describe('AccessControlConfigPanel', () => { expect(screen.getByRole('switch', { name: 'Trigger' })).not.toBeChecked() }) }) + +it('ignores pointer and keyboard scope changes until a policy is selected', async () => { + const user = userEvent.setup() + render() + const scope = screen.getByRole('switch', { name: 'Web App' }) + await user.click(scope) + expect(scope).toBeChecked() + expect(scope).toHaveAttribute('tabindex', '-1') + scope.focus() + await user.keyboard(' {Enter}') + expect(scope).toBeChecked() + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await user.click(scope) + expect(scope).not.toBeChecked() +}) + +it('requires a policy that is still present in the workspace before enabling configuration', () => { + render( + , + ) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') +}) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 92f4bbc38c52b1..9b017fc757e3e8 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -253,15 +253,12 @@ describe('AccessControlEntry', () => { ).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() - expect(screen.getByRole('switch', { name: 'Web App' })).not.toHaveAttribute( - 'aria-disabled', - 'true', - ) - expect(screen.getByRole('switch', { name: 'Backend Service API' })).not.toHaveAttribute( + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('switch', { name: 'Backend Service API' })).toHaveAttribute( 'aria-disabled', 'true', ) - expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toHaveAttribute( + expect(screen.getByRole('switch', { name: 'MCP Server' })).toHaveAttribute( 'aria-disabled', 'true', ) @@ -429,14 +426,16 @@ describe('AccessControlEntry', () => { it('discards an unsaved first-time draft on Cancel', async () => { const user = userEvent.setup() - renderEntry({ plan: 'professional' }) + renderEntry({ plan: 'professional', groups: [createNetworkAccessGroupFixture()] }) await user.click(getChip()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) await user.click(screen.getByRole('switch', { name: 'Web App' })) await user.click(screen.getByRole('button', { name: 'Cancel' })) await waitFor(() => { - expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() }) await user.click(getChip()) @@ -446,22 +445,26 @@ describe('AccessControlEntry', () => { it('restores an unsaved first-time draft after Escape', async () => { const user = userEvent.setup() - renderEntry({ plan: 'professional' }) + renderEntry({ plan: 'professional', groups: [createNetworkAccessGroupFixture()] }) await user.click(getChip()) + await user.click(screen.getByRole('option', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) await user.click(screen.getByRole('switch', { name: 'Web App' })) expect(screen.getByRole('switch', { name: 'Web App' })).not.toBeChecked() await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByText('No IP policies in this workspace yet')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() }) await user.click(getChip()) - expect(screen.getByText('No IP policies in this workspace yet')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'IP Policy' })).toHaveTextContent( + 'Internal Network', + ) expect(screen.getByRole('switch', { name: 'Web App' })).not.toBeChecked() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) }) it('restores an unsaved edit after Escape and keeps the saved chip', async () => { diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index a4a7f6f5627cf9..922171e3317a91 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -61,7 +61,7 @@ export function AccessControlConfigPanel({ const policyErrorId = useId() const accessPointErrorId = useId() const title = t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' }) - const hasSelectedPolicy = Boolean(draft.selectedPolicyId) + const hasSelectedPolicy = policies.some((policy) => policy.id === draft.selectedPolicyId) const hasPersistableSelection = hasSelectedAccessPoint(draft, availableAccessPoints) const showPolicyError = draft.enabled && !hasSelectedPolicy const showAccessPointError = draft.enabled && !hasPersistableSelection @@ -159,7 +159,7 @@ export function AccessControlConfigPanel({ draft={draft} appIcon={appIcon} availableAccessPoints={availableAccessPoints} - readOnly={readOnly || saving} + readOnly={readOnly || saving || !hasSelectedPolicy} onDraftChange={onDraftChange} /> {showAccessPointError && ( From df65412336534e8e192e26f89f148a5e592e066b Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:30:37 +0800 Subject: [PATCH 050/108] fix(web): persist access control pause and resume immediately (SNP-827) --- .../access-control/__tests__/index.spec.tsx | 131 +++++++++--------- .../app/access-point/access-control/index.tsx | 26 ++-- .../access-control/status-panel.tsx | 49 ++----- 3 files changed, 100 insertions(+), 106 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 9b017fc757e3e8..f3c9e51db36de1 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import type { GetWorkspacesCurrentSummaryResponse, NetworkAccessGroupResponse, } from '@dify/contracts/api/console/workspaces/types.gen' -import { screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { consoleQuery } from '@/service/console' import { @@ -398,32 +398,6 @@ describe('AccessControlEntry', () => { expect(screen.getByRole('switch', { name: 'Enable access control' })).toBeChecked() }) - it('keeps the saved chip state until a turned-off draft is saved', async () => { - const user = userEvent.setup() - renderEntry({ - plan: 'professional', - groups: [createNetworkAccessGroupFixture()], - binding: { - id: 'binding-1', - tenant_id: 'workspace-1', - app_id: 'app-1', - enabled: true, - group_id: 'group-1', - access_points: ['webapp', 'service_api', 'mcp'], - version: 2, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - }, - }) - - await user.click(getChip()) - await user.click(screen.getByRole('switch', { name: 'Enable access control' })) - await user.click(screen.getByRole('button', { name: 'Turn off' })) - - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('ON')).toBeInTheDocument() - }) - it('discards an unsaved first-time draft on Cancel', async () => { const user = userEvent.setup() renderEntry({ plan: 'professional', groups: [createNetworkAccessGroupFixture()] }) @@ -610,41 +584,6 @@ describe('AccessControlEntry', () => { expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument() }) - it('reopens the edit layer after dismissing a paused draft with Escape', async () => { - const user = userEvent.setup() - renderEntry({ - plan: 'professional', - groups: [createNetworkAccessGroupFixture()], - binding: { - id: 'binding-1', - tenant_id: 'workspace-1', - app_id: 'app-1', - enabled: true, - group_id: 'group-1', - access_points: ['webapp', 'service_api', 'mcp'], - version: 2, - created_at: '2026-01-01T00:00:00Z', - updated_at: '2026-01-01T00:00:00Z', - }, - }) - - await user.click(getChip()) - await user.click(screen.getByRole('switch', { name: 'Enable access control' })) - await user.click(screen.getByRole('button', { name: 'Turn off' })) - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - - await user.keyboard('{Escape}') - await waitFor(() => { - expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() - }) - expect(within(getChip()).getByText('ON')).toBeInTheDocument() - - await user.click(getChip()) - expect(screen.queryByText('Restricted to Internal Network')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() - expect(within(getChip()).getByText('ON')).toBeInTheDocument() - }) }) const capabilityCases = [ @@ -817,6 +756,8 @@ describe('supported access points and binding permission', () => { const payloads: unknown[] = [] const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const request = new Request(input, init) + const metadata = metadataResponse(request) + if (metadata) return metadata if (request.url.endsWith('/workspaces/current/network-access-groups')) return Response.json({ tenant_id: 'workspace-1', @@ -850,7 +791,6 @@ describe('supported access points and binding permission', () => { 'Web App, MCP Server will be reachable from any IP.', ) await user.click(screen.getByRole('button', { name: 'Turn off' })) - await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(payloads).toEqual([ { @@ -865,8 +805,12 @@ describe('supported access points and binding permission', () => { expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(), ) expect(screen.getByRole('switch', { name: 'Enable access control' })).not.toBeChecked() + expect( + screen.getByText('Paused — Internal Network is configured but not enforcing'), + ).toBeInTheDocument() + expect(screen.queryByText(/Protecting/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() await user.click(screen.getByRole('switch', { name: 'Enable access control' })) - await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(payloads).toHaveLength(2)) expect(payloads[1]).toEqual({ enabled: true, @@ -1232,3 +1176,62 @@ describe('trusted IP checks before saving', () => { expect(server.writes).toHaveLength(0) }) }) + +describe('immediate pause and resume', () => { + it('cancels pause without writing or changing the saved status', async () => { + const user = userEvent.setup() + const server = setupBindingServer(createBinding()) + renderEntry({ plan: 'professional', groups: server.groups, binding: server.binding }) + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Enable access control' })) + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }), + ) + expect(screen.getByRole('switch', { name: 'Enable access control' })).toBeChecked() + expect(server.writes).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) + + it.each([500, 409])( + 'keeps the saved state and prevents duplicate requests when pause fails (%s)', + async (status) => { + const user = userEvent.setup() + const server = setupBindingServer(createBinding({ access_points: ['webapp', 'mcp'] })) + let finish!: (response: Response) => void + server.put = () => + new Promise((resolve) => { + finish = resolve + }) + renderEntry({ plan: 'professional', groups: server.groups, binding: server.binding }) + await user.click(getChip()) + await user.click(screen.getByRole('switch', { name: 'Enable access control' })) + await user.click(screen.getByRole('button', { name: 'Turn off' })) + const toggle = screen.getByRole('switch', { name: 'Enable access control' }) + await waitFor(() => expect(toggle).toHaveAttribute('aria-disabled', 'true')) + await user.click(toggle) + expect(server.writes).toEqual([ + { + enabled: false, + group_id: 'group-1', + access_points: ['webapp', 'mcp'], + expected_version: 2, + }, + ]) + if (status === 409) + server.binding = createBinding({ version: 3, access_points: ['webapp', 'mcp'] }) + await act(async () => finish(Response.json({ message: 'Unable to pause' }, { status }))) + await waitFor(() => expect(toggle).not.toHaveAttribute('aria-disabled', 'true')) + expect(toggle).toBeChecked() + expect(within(getChip()).getByText('2 of 3')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + if (status === 409) { + await user.click(toggle) + await user.click(screen.getByRole('button', { name: 'Turn off' })) + await waitFor(() => expect(server.writes[1]).toMatchObject({ expected_version: 3 })) + await act(async () => + finish(Response.json({ message: 'Unable to pause' }, { status: 500 })), + ) + } + }, + ) +}) diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index 0c901b9566bc7f..1f4fbd6309c597 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -256,7 +256,7 @@ function AccessControlSession({ }, onSuccess?: () => void, ) => { - if (!canMutate) return + if (!canMutate || updateBinding.isPending) return updateBinding.mutate( { @@ -269,7 +269,15 @@ function AccessControlSession({ }, }, { - onSuccess, + onSuccess: (data) => { + queryClient.setQueryData( + consoleQuery.apps.byAppId.networkAccessGroup.get.queryKey({ + input: { params: { app_id: appId } }, + }), + (current) => (current ? { ...current, ...data } : current), + ) + onSuccess?.() + }, onError: (error) => { if (getNetworkAccessErrorStatus(error) !== 409) return void queryClient.invalidateQueries({ @@ -343,8 +351,14 @@ function AccessControlSession({ } const handleEnabledChange = (enabled: boolean) => { - if (!canMutate) return - setDraft({ ...resolvedDraft, enabled }) + if (!canMutate || updateBinding.isPending || !binding?.group_id) return + persistBinding( + { enabled, groupId: binding.group_id, accessPoints: binding.access_points }, + () => { + discardDraft() + setView('status') + }, + ) } return ( @@ -403,12 +417,8 @@ function AccessControlSession({ policies={policies} enabled={resolvedDraft.enabled} updating={updateBinding.isPending} - dirty={dirty} - canSave={canSave} readOnly={!canMutate} onEnabledChange={handleEnabledChange} - onCancel={handleCancel} - onSave={handleSave} onEdit={() => { if (!canMutate) return setDraft(resolvedDraft) diff --git a/web/app/components/app/access-point/access-control/status-panel.tsx b/web/app/components/app/access-point/access-control/status-panel.tsx index 2617fe8fbfc322..f9323bac1c6e9d 100644 --- a/web/app/components/app/access-point/access-control/status-panel.tsx +++ b/web/app/components/app/access-point/access-control/status-panel.tsx @@ -36,11 +36,7 @@ type AccessControlStatusPanelProps = { enabled: boolean onEdit: () => void updating?: boolean - dirty?: boolean - canSave?: boolean readOnly?: boolean - onCancel?: () => void - onSave?: () => void onEnabledChange: (enabled: boolean) => void } @@ -52,11 +48,7 @@ export function AccessControlStatusPanel({ enabled, onEdit, updating = false, - dirty = false, - canSave = false, readOnly = false, - onCancel, - onSave, onEnabledChange, }: AccessControlStatusPanelProps) { const { t } = useTranslation() @@ -79,7 +71,7 @@ export function AccessControlStatusPanel({
    - {policyName + {policyName && enabled ? t(($) => $['studio.accessControl.restrictedTo'], { ns: 'deployments', name: policyName, @@ -87,7 +79,7 @@ export function AccessControlStatusPanel({ : t(($) => $['studio.accessControl.entryLabel'], { ns: 'deployments' })} {!readOnly && ( - )} @@ -100,16 +92,21 @@ export function AccessControlStatusPanel({ {t(($) => $['studio.accessControl.restrictByIp'], { ns: 'deployments' })}

    - {coverage.inServiceCount > 0 && coverage.coveredCount === coverage.inServiceCount - ? t(($) => $['studio.accessControl.protectingAll'], { + {!enabled + ? t(($) => $['studio.accessControl.tooltipPaused'], { ns: 'deployments', - count: coverage.inServiceCount, + name: policyName ?? '', }) - : t(($) => $['studio.accessControl.protectingPartial'], { - ns: 'deployments', - n: coverage.coveredCount, - m: coverage.inServiceCount, - })} + : coverage.inServiceCount > 0 && coverage.coveredCount === coverage.inServiceCount + ? t(($) => $['studio.accessControl.protectingAll'], { + ns: 'deployments', + count: coverage.inServiceCount, + }) + : t(($) => $['studio.accessControl.protectingPartial'], { + ns: 'deployments', + n: coverage.coveredCount, + m: coverage.inServiceCount, + })}

    - {dirty && !readOnly && ( -
    - - -
    - )}
    From 0f487e51bf57b8a10701de64d8981e37ccfc6e7a Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Sun, 20 Sep 2026 17:30:48 +0800 Subject: [PATCH 051/108] fix(web): preserve drafts and expand newly created policies (SNP-834) --- .../access-control/__tests__/index.spec.tsx | 52 +++++++++++++++++++ .../access-control/config-panel.tsx | 6 +++ .../app/access-point/access-control/index.tsx | 9 ++-- .../access-control/policy-field.tsx | 11 +++- 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index f3c9e51db36de1..f7fa1b7d095c63 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -344,6 +344,13 @@ describe('AccessControlEntry', () => { }, }) }) + expect(await screen.findByRole('option', { name: /Office/ })).toHaveAttribute( + 'aria-selected', + 'false', + ) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + expect(screen.getByRole('switch', { name: 'Web App' })).toHaveAttribute('aria-disabled', 'true') + expect(posted.filter((request) => request.method === 'PUT')).toHaveLength(0) expect(mockSetSettingsDestination).not.toHaveBeenCalled() }) @@ -1235,3 +1242,48 @@ describe('immediate pause and resume', () => { }, ) }) + +describe('returning from policy creation', () => { + it.each(['create', 'cancel', 'failure'] as const)( + 'preserves the selected policy and scope draft after %s', + async (action) => { + const user = userEvent.setup() + const server = setupBindingServer() + renderEntry({ plan: 'professional', groups: server.groups }) + await openSelectedConfig(user) + await screen.findByText(lockoutWarning) + await user.click(screen.getByRole('switch', { name: 'MCP Server' })) + await user.click(screen.getByRole('combobox', { name: 'IP Policy' })) + await user.click(screen.getByRole('option', { name: 'Add IP Policy' })) + const dialog = await screen.findByRole('dialog') + if (action !== 'cancel') { + await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), 'New Office') + await user.type(within(dialog).getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + if (action === 'failure') server.createStatus = 500 + await user.click(within(dialog).getByRole('button', { name: 'Create' })) + } + if (action === 'failure') { + await waitFor(() => + expect(within(dialog).getByRole('button', { name: 'Create' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ), + ) + expect(dialog).toBeInTheDocument() + } + if (action !== 'create') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + const select = await screen.findByRole('combobox', { name: 'IP Policy' }) + expect(select).toHaveTextContent('Internal Network') + if (action === 'create') { + expect(await screen.findByRole('option', { name: /New Office/ })).toHaveAttribute( + 'aria-selected', + 'false', + ) + await user.keyboard('{Escape}') + } + expect(screen.getByRole('switch', { name: 'MCP Server' })).not.toBeChecked() + expect(server.writes).toHaveLength(0) + }, + ) +}) diff --git a/web/app/components/app/access-point/access-control/config-panel.tsx b/web/app/components/app/access-point/access-control/config-panel.tsx index 922171e3317a91..54b88ebccc1ca0 100644 --- a/web/app/components/app/access-point/access-control/config-panel.tsx +++ b/web/app/components/app/access-point/access-control/config-panel.tsx @@ -22,6 +22,8 @@ type AccessControlConfigPanelProps = { ipCheck?: NetworkAccessGroupCurrentIpCheckResponse ipCheckStatus?: 'loading' | 'error' onRetryIpCheck?: () => void + policySelectOpen?: boolean + onPolicySelectOpenChange?: (open: boolean) => void availableAccessPoints: readonly AccessPoint[] appIcon: AccessControlAppIcon readOnly?: boolean @@ -43,6 +45,8 @@ export function AccessControlConfigPanel({ ipCheck, ipCheckStatus, onRetryIpCheck, + policySelectOpen, + onPolicySelectOpenChange, availableAccessPoints, appIcon, readOnly = false, @@ -127,6 +131,8 @@ export function AccessControlConfigPanel({ ipCheck={ipCheck} ipCheckStatus={ipCheckStatus} onRetryIpCheck={onRetryIpCheck} + open={policySelectOpen} + onOpenChange={onPolicySelectOpenChange} readOnly={readOnly || saving} canManagePolicies={canManagePolicies} onCreatePolicy={onCreatePolicy} diff --git a/web/app/components/app/access-point/access-control/index.tsx b/web/app/components/app/access-point/access-control/index.tsx index 1f4fbd6309c597..b5c0c57690c764 100644 --- a/web/app/components/app/access-point/access-control/index.tsx +++ b/web/app/components/app/access-point/access-control/index.tsx @@ -107,6 +107,7 @@ function AccessControlSession({ const [showBack, setShowBack] = useState(false) const [draft, setDraft] = useState(null) const [createPolicyOpen, setCreatePolicyOpen] = useState(false) + const [policySelectOpen, setPolicySelectOpen] = useState(false) const [confirmation, setConfirmation] = useState<{ draft: AccessControlDraft clientIp: string @@ -209,6 +210,7 @@ function AccessControlSession({ saveAttemptRef.current += 1 saveCheck.reset() setConfirmation(null) + setPolicySelectOpen(false) setDraft(null) setShowBack(false) } @@ -445,6 +447,8 @@ function AccessControlSession({ saveCheck.reset() void ipCheck.refetch() }} + policySelectOpen={policySelectOpen} + onPolicySelectOpenChange={setPolicySelectOpen} canManagePolicies={canManagePolicies} baseline={baseline} showBack={showBack} @@ -496,12 +500,9 @@ function AccessControlSession({ } }, ) - setDraft({ - ...resolvedDraft, - selectedPolicyId: data.group.id, - }) setCreatePolicyOpen(false) setView('config') + setPolicySelectOpen(true) setOpen(true) }, }, diff --git a/web/app/components/app/access-point/access-control/policy-field.tsx b/web/app/components/app/access-point/access-control/policy-field.tsx index fafb5cd89a318b..2162d14e05dfb1 100644 --- a/web/app/components/app/access-point/access-control/policy-field.tsx +++ b/web/app/components/app/access-point/access-control/policy-field.tsx @@ -27,6 +27,8 @@ type AccessControlPolicyFieldProps = { ipCheck?: NetworkAccessGroupCurrentIpCheckResponse ipCheckStatus?: 'loading' | 'error' onRetryIpCheck?: () => void + open?: boolean + onOpenChange?: (open: boolean) => void readOnly?: boolean canManagePolicies: boolean onCreatePolicy: () => void @@ -40,6 +42,8 @@ export function AccessControlPolicyField({ ipCheck, ipCheckStatus, onRetryIpCheck, + open, + onOpenChange, readOnly = false, canManagePolicies, onCreatePolicy, @@ -143,7 +147,10 @@ export function AccessControlPolicyField({ disabled={readOnly} className="flex h-8 w-full cursor-pointer items-center rounded-lg px-2 text-left system-sm-medium text-text-secondary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" onClick={() => { - if (!readOnly) onSelectPolicy(policy.id) + if (!readOnly) { + onSelectPolicy(policy.id) + onOpenChange?.(false) + } }} > {policy.name} @@ -179,6 +186,8 @@ export function AccessControlPolicyField({
    + + + + ) +} + +async function renderAccount() { + const { wrapper: ConsoleProvider } = createConsoleQueryWrapper({ + accountProfile: { name: 'Current User', email: 'current@example.com' }, + features: { education: { enabled: false } }, + }) + const { wrapper: NuqsProvider } = createNuqsTestWrapper() + return render( + + +
    + + +
    + +
    +
    , + ) +} + +// Chromium supplies the native focus-visible heuristic after menu focus restoration. +it.each(['outside', 'Escape'])( + 'does not highlight a pointer-opened account trigger after %s dismissal', + async (dismiss) => { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + await trigger.click() + await expect.element(screen.getByRole('menu')).toBeVisible() + if (dismiss === 'Escape') await userEvent.keyboard('{Escape}') + else + await screen + .getByRole('presentation') + .filter({ hasNotText: 'current@example.com' }) + .click({ position: { x: 10, y: 600 } }) + await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() + if (dismiss === 'Escape') await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(false) + }, +) + +it('keeps focus in Preferences after the account menu finishes closing', async () => { + const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } + const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED + settings.BASE_UI_ANIMATIONS_DISABLED = false + try { + const screen = await renderAccount() + await screen + .getByRole('button', { name: 'Current User accountSettings.account.account' }) + .click() + await screen.getByRole('menuitem', { name: 'navigation.settings.preferences' }).click() + const input = screen.getByRole('textbox', { name: 'Preference' }) + await expect.element(input).toHaveFocus() + await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() + await expect.element(input).toHaveFocus() + } finally { + settings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled + } +}) + +it.each(['keyboard', 'pointer then keyboard'])( + 'keeps visible focus after %s navigation and Escape dismissal', + async (interaction) => { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + if (interaction === 'keyboard') { + await screen.getByRole('button', { name: 'Before account' }).click() + await userEvent.keyboard('{Tab}{Enter}') + } else { + await trigger.click() + await userEvent.keyboard('{ArrowDown}') + } + await expect.element(screen.getByRole('menu')).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(true) + expect(getComputedStyle(trigger.element()).boxShadow).not.toBe('none') + }, +) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index a9ecc994bfa865..4ef253a77dfc65 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -22,7 +22,7 @@ import { import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useQueryState } from 'nuqs' -import { useSyncExternalStore } from 'react' +import { useRef, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import { @@ -214,6 +214,9 @@ function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) { } export default function AccountDropdown({ trigger }: AccountDropdownProps) { + const triggerRef = useRef(null) + const popupRef = useRef(null) + const keyboardNavigationRef = useRef(false) const isHydrating = useSyncExternalStore( subscribeHydrationState, getHydrationSnapshot, @@ -225,14 +228,42 @@ export default function AccountDropdown({ trigger }: AccountDropdownProps) { return (
    - + { + if (open) + keyboardNavigationRef.current = + event.type.startsWith('key') || + (event.type === 'click' && 'detail' in event && event.detail === 0) + }} + > { + if (event.key !== 'Escape') keyboardNavigationRef.current = true + }} disabled={isHydrating} render={trigger({ ariaLabel: t(($) => $['account.account'], { ns: 'accountSettings' }), })} /> { + if (event.key !== 'Escape') keyboardNavigationRef.current = true + }} + finalFocus={(interactionType) => { + const activeElement = document.activeElement + if ( + activeElement !== document.body && + activeElement !== triggerRef.current && + !popupRef.current?.contains(activeElement) + ) + return false + if (keyboardNavigationRef.current || interactionType !== 'keyboard') return true + // Escape alone should not turn a pointer-opened menu into keyboard navigation. + triggerRef.current?.focus({ preventScroll: true, focusVisible: false }) + return false + }} placement="top-start" sideOffset={6} alignOffset={4} From a76ed55f9a55a1d00b87d57ea7e135a71e9bbbb8 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 12 Oct 2026 17:45:28 +0800 Subject: [PATCH 096/108] fix(web): align sandbox access control upgrade copy (SNP-955) --- .../access-control/__tests__/index.spec.tsx | 14 ++++++++------ web/i18n/locales/en-US/deployments.json | 6 +++--- web/i18n/locales/ja-JP/deployments.json | 8 ++++---- web/i18n/locales/zh-Hans/deployments.json | 6 +++--- web/i18n/resources.generated.d.ts | 6 +++--- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index ef1d44c4d551d5..067659f9d67908 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -322,9 +322,7 @@ describe('AccessControlEntry', () => { expect(within(chip).getByText('PRO')).toBeInTheDocument() expect(within(chip).queryByRole('button')).not.toBeInTheDocument() await user.hover(chip) - expect( - await screen.findByText('Access control is available on Professional and Team plans.'), - ).toBeInTheDocument() + expect(await screen.findByText('Available on Professional and Team plans')).toBeInTheDocument() expect(screen.queryByText(/No longer protected/)).not.toBeInTheDocument() }) @@ -369,7 +367,11 @@ describe('AccessControlEntry', () => { await user.click(getChip()) - expect(screen.getByText('Restrict this app to IP addresses you trust.')).toBeInTheDocument() + expect( + screen.getByRole('dialog', { name: 'Restrict this app to IP addresses you trust' }), + ).toHaveAccessibleDescription( + 'Only requests from the IP policies you choose can reach this app’s access points. Available on Professional and Team plans.', + ) expect( screen.getByText("This app is only available on your organization's network."), ).toBeInTheDocument() @@ -393,13 +395,13 @@ describe('AccessControlEntry', () => { renderEntry({ plan: 'sandbox' }) await user.click(getChip()) - expect(screen.getByText('Restrict this app to IP addresses you trust.')).toBeInTheDocument() + expect(screen.getByText('Restrict this app to IP addresses you trust')).toBeInTheDocument() await user.keyboard('{Escape}') await waitFor(() => { expect( - screen.queryByText('Restrict this app to IP addresses you trust.'), + screen.queryByText('Restrict this app to IP addresses you trust'), ).not.toBeInTheDocument() }) expect(mockSetPricing).not.toHaveBeenCalled() diff --git a/web/i18n/locales/en-US/deployments.json b/web/i18n/locales/en-US/deployments.json index a0dee02cad75d9..ab5f122f9d1c72 100644 --- a/web/i18n/locales/en-US/deployments.json +++ b/web/i18n/locales/en-US/deployments.json @@ -53,8 +53,8 @@ "studio.accessControl.manageIpPolicies": "Manage IP policies", "studio.accessControl.notEnabled": "Not protected", "studio.accessControl.pausedDescription": "Paused — {{name}} is configured but not enforcing", - "studio.accessControl.paywallDescription": "Restrict this app to IP addresses you trust.", - "studio.accessControl.paywallTitle": "Access Control", + "studio.accessControl.paywallDescription": "Only requests from the IP policies you choose can reach this app’s access points. Available on Professional and Team plans.", + "studio.accessControl.paywallTitle": "Restrict this app to IP addresses you trust", "studio.accessControl.policyChanged": "This policy changed. Review the warning before saving again.", "studio.accessControl.policySummaryMany_one": "Allows {{first}}, {{second}} and {{count}} more address", "studio.accessControl.policySummaryMany_other": "Allows {{first}}, {{second}} and {{count}} more addresses", @@ -79,7 +79,7 @@ "studio.accessControl.tooltipDowngraded": "No longer protected — available on Professional and Team plans", "studio.accessControl.tooltipOff": "Not set up", "studio.accessControl.tooltipPaused": "Configured but paused", - "studio.accessControl.tooltipPro": "Access control is available on Professional and Team plans.", + "studio.accessControl.tooltipPro": "Available on Professional and Team plans", "studio.accessControl.tooltipProtected": "{{n}} of {{total}} access points are protected", "studio.accessControl.turnOffConfirm": "Turn off", "studio.accessControl.turnOffDescription": "All access points will be reachable from any IP address. Your configuration will be kept.", diff --git a/web/i18n/locales/ja-JP/deployments.json b/web/i18n/locales/ja-JP/deployments.json index 5ba719e179ac5b..9211c058000e75 100644 --- a/web/i18n/locales/ja-JP/deployments.json +++ b/web/i18n/locales/ja-JP/deployments.json @@ -53,8 +53,8 @@ "studio.accessControl.manageIpPolicies": "IP ポリシーを管理", "studio.accessControl.notEnabled": "保護されていません", "studio.accessControl.pausedDescription": "一時停止中、{{name}} は設定済みですが適用されていません", - "studio.accessControl.paywallDescription": "信頼できる IP アドレスからのみ、このアプリにアクセスできるようにします。", - "studio.accessControl.paywallTitle": "アクセス制御", + "studio.accessControl.paywallDescription": "選択した IP ポリシーからのリクエストのみがこのアプリのアクセスポイントに到達できます。Professional および Team プランでご利用いただけます。", + "studio.accessControl.paywallTitle": "信頼できる IP アドレスのみにこのアプリへのアクセスを制限", "studio.accessControl.policyChanged": "このポリシーは変更されました。警告を確認してから再度保存してください。", "studio.accessControl.policySummaryMany_one": "{{first}}、{{second}} と他 {{count}} 件のアドレスを許可", "studio.accessControl.policySummaryMany_other": "{{first}}、{{second}} と他 {{count}} 件のアドレスを許可", @@ -79,12 +79,12 @@ "studio.accessControl.tooltipDowngraded": "保護されていません。Professional および Team プランでご利用いただけます", "studio.accessControl.tooltipOff": "未設定", "studio.accessControl.tooltipPaused": "設定済み、一時停止中", - "studio.accessControl.tooltipPro": "アクセス制御は Professional および Team プランで利用できます。", + "studio.accessControl.tooltipPro": "Professional および Team プランでご利用いただけます", "studio.accessControl.tooltipProtected": "アクセスポイント {{total}} 個中 {{n}} 個が保護されています", "studio.accessControl.turnOffConfirm": "オフにする", "studio.accessControl.turnOffDescription": "すべてのアクセスポイントが任意の IP アドレスからアクセス可能になります。設定内容は保持されます。", "studio.accessControl.turnOffTitle": "IP アクセス制御をオフにしますか?", - "studio.accessControl.turnOn": "アクセス制御をオンにする", + "studio.accessControl.turnOn": "アクセス制御を有効にする", "studio.accessControl.viewIpPolicies": "IP ポリシーを表示", "studio.accessPoint.apiDescription": "HTTP 経由でアプリをサービスに統合", "studio.accessPoint.description": "このアプリにアクセスするすべての方法。アクセスポイントは実行中のバージョンのみに対応します。", diff --git a/web/i18n/locales/zh-Hans/deployments.json b/web/i18n/locales/zh-Hans/deployments.json index 0ca118ece3ed0b..f95940ce4db1ee 100644 --- a/web/i18n/locales/zh-Hans/deployments.json +++ b/web/i18n/locales/zh-Hans/deployments.json @@ -53,8 +53,8 @@ "studio.accessControl.manageIpPolicies": "管理 IP 策略", "studio.accessControl.notEnabled": "未保护", "studio.accessControl.pausedDescription": "已暂停,{{name}} 已配置但未生效", - "studio.accessControl.paywallDescription": "将此应用限制为仅允许你信任的 IP 地址访问。", - "studio.accessControl.paywallTitle": "访问控制", + "studio.accessControl.paywallDescription": "只有来自所选 IP 策略的请求才能访问此应用的访问入口。适用于 Professional 和 Team 套餐。", + "studio.accessControl.paywallTitle": "仅允许可信 IP 地址访问此应用", "studio.accessControl.policyChanged": "此策略已发生变化,请确认风险提示后再次保存。", "studio.accessControl.policySummaryMany_one": "允许 {{first}}、{{second}} 及另外 {{count}} 个地址", "studio.accessControl.policySummaryMany_other": "允许 {{first}}、{{second}} 及另外 {{count}} 个地址", @@ -79,7 +79,7 @@ "studio.accessControl.tooltipDowngraded": "已不再受保护,适用于 Professional 和 Team 套餐", "studio.accessControl.tooltipOff": "尚未配置", "studio.accessControl.tooltipPaused": "已配置但已暂停", - "studio.accessControl.tooltipPro": "访问控制功能适用于 Professional 和 Team 套餐。", + "studio.accessControl.tooltipPro": "适用于 Professional 和 Team 套餐", "studio.accessControl.tooltipProtected": "{{total}} 个访问入口中的 {{n}} 个已保护", "studio.accessControl.turnOffConfirm": "关闭", "studio.accessControl.turnOffDescription": "所有访问入口将对任意 IP 地址开放。您的配置会被保留。", diff --git a/web/i18n/resources.generated.d.ts b/web/i18n/resources.generated.d.ts index aef257c31a6dec..838076ad6f88bb 100644 --- a/web/i18n/resources.generated.d.ts +++ b/web/i18n/resources.generated.d.ts @@ -3013,8 +3013,8 @@ export default interface Resources { 'studio.accessControl.manageIpPolicies': 'Manage IP policies' 'studio.accessControl.notEnabled': 'Not protected' 'studio.accessControl.pausedDescription': 'Paused — {{name}} is configured but not enforcing' - 'studio.accessControl.paywallDescription': 'Restrict this app to IP addresses you trust.' - 'studio.accessControl.paywallTitle': 'Access Control' + 'studio.accessControl.paywallDescription': 'Only requests from the IP policies you choose can reach this app’s access points. Available on Professional and Team plans.' + 'studio.accessControl.paywallTitle': 'Restrict this app to IP addresses you trust' 'studio.accessControl.policyChanged': 'This policy changed. Review the warning before saving again.' 'studio.accessControl.policySummaryMany': | 'Allows {{first}}, {{second}} and {{count}} more address' @@ -3040,7 +3040,7 @@ export default interface Resources { 'studio.accessControl.tooltipDowngraded': 'No longer protected — available on Professional and Team plans' 'studio.accessControl.tooltipOff': 'Not set up' 'studio.accessControl.tooltipPaused': 'Configured but paused' - 'studio.accessControl.tooltipPro': 'Access control is available on Professional and Team plans.' + 'studio.accessControl.tooltipPro': 'Available on Professional and Team plans' 'studio.accessControl.tooltipProtected': '{{n}} of {{total}} access points are protected' 'studio.accessControl.turnOffConfirm': 'Turn off' 'studio.accessControl.turnOffDescription': 'All access points will be reachable from any IP address. Your configuration will be kept.' From 1ac214e91e4eeade8a0c3d9832bb58471913a0ab Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 12 Oct 2026 22:57:41 +0800 Subject: [PATCH 097/108] fix: clear stale account menu pointer focus ring (SNP-952) --- .../__tests__/focus.browser.spec.tsx | 37 +++++++++++++++++++ .../header/account-dropdown/index.tsx | 32 ++++++++++++---- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx b/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx index 48260fba07790b..6948fae18cb696 100644 --- a/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx @@ -112,3 +112,40 @@ it.each(['keyboard', 'pointer then keyboard'])( expect(getComputedStyle(trigger.element()).boxShadow).not.toBe('none') }, ) + +it('clears the keyboard focus ring when the same account menu is reopened and dismissed with the pointer', async () => { + const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } + const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED + settings.BASE_UI_ANIMATIONS_DISABLED = false + try { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + const menu = screen.getByRole('menu') + const dismissWithPointer = async () => { + await trigger.click() + await expect.element(menu).toBeVisible() + await screen + .getByRole('presentation') + .filter({ hasNotText: 'current@example.com' }) + .click({ position: { x: 10, y: 600 } }) + await expect.element(menu).not.toBeInTheDocument() + expect(trigger.element().matches(':focus-visible')).toBe(false) + } + + await dismissWithPointer() + + await screen.getByRole('button', { name: 'Before account' }).click() + await userEvent.keyboard('{Tab}{Enter}{ArrowDown}') + await expect.element(menu).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(menu).not.toBeInTheDocument() + await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(true) + + await dismissWithPointer() + } finally { + settings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled + } +}) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 4ef253a77dfc65..bfd2519d18255e 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -252,16 +252,32 @@ export default function AccountDropdown({ trigger }: AccountDropdownProps) { if (event.key !== 'Escape') keyboardNavigationRef.current = true }} finalFocus={(interactionType) => { - const activeElement = document.activeElement + const canRestoreFocus = () => { + const activeElement = document.activeElement + return ( + activeElement === document.body || + activeElement === triggerRef.current || + !!popupRef.current?.contains(activeElement) + ) + } if ( - activeElement !== document.body && - activeElement !== triggerRef.current && - !popupRef.current?.contains(activeElement) + interactionType === 'touch' || + interactionType === '' || + (interactionType === 'keyboard' && keyboardNavigationRef.current) ) - return false - if (keyboardNavigationRef.current || interactionType !== 'keyboard') return true - // Escape alone should not turn a pointer-opened menu into keyboard navigation. - triggerRef.current?.focus({ preventScroll: true, focusVisible: false }) + return canRestoreFocus() + // Restore after dismissal, respecting focus moved into a destination dialog. + queueMicrotask(() => { + if (!canRestoreFocus()) return + const triggerElement = triggerRef.current + // Focusing an already focused element does not update its focus-visible state. + if ( + document.activeElement === triggerElement && + triggerElement?.matches(':focus-visible') + ) + triggerElement.blur() + triggerElement?.focus({ preventScroll: true, focusVisible: false }) + }) return false }} placement="top-start" From 2b8d97b645a01f638856b3c1754e5fb678e2b0d4 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 00:08:15 +0800 Subject: [PATCH 098/108] fix(web): extend IP policy row interaction area (SNP-956) --- .../__tests__/policy-item.browser.spec.tsx | 78 +++++++++++++++++++ .../ip-policies-page/policy-item.tsx | 11 ++- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx new file mode 100644 index 00000000000000..4d8b2c16c5c64a --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx @@ -0,0 +1,78 @@ +import { userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { createNetworkAccessGroupFixture } from '@/test/console/network-access' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { PolicyItem } from '../policy-item' + +vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) + +async function renderPolicy(canMutate = true) { + const group = createNetworkAccessGroupFixture() + const onView = vi.fn() + const onEdit = vi.fn() + const { wrapper: ConsoleProvider } = createConsoleQueryWrapper() + const screen = await render( + + +
    + +
    +
    , + ) + return { screen, group, onView, onEdit } +} + +// Real hit testing catches the row padding that unit clicks on the name button do not reach. +it.each([true, false])( + 'opens the policy from padding on every side of the row (can manage: %s)', + async (canMutate) => { + const { screen, group, onView, onEdit } = await renderPolicy(canMutate) + const row = screen.getByRole('region', { name: 'IP policy row' }) + const { width, height } = row.element().getBoundingClientRect() + const positions = [ + { x: width / 2, y: 2 }, + { x: width / 2, y: height - 2 }, + { x: 2, y: height / 2 }, + { x: width - 2, y: height / 2 }, + ] + if (!canMutate) positions.push({ x: width - 20, y: height / 2 }) + + for (const [index, position] of positions.entries()) { + await row.click({ position }) + expect(onView).toHaveBeenCalledTimes(index + 1) + expect(onView).toHaveBeenLastCalledWith(group) + } + expect(onEdit).not.toHaveBeenCalled() + }, +) + +it('keeps the row and its separate actions reachable with pointer and keyboard', async () => { + const { screen, group, onView, onEdit } = await renderPolicy() + const policy = screen.getByRole('button', { name: group.name }) + const more = screen.getByRole('button', { name: /common.operation.moreActionsFor/ }) + + await more.click() + await screen.getByRole('menuitem', { name: 'common.operation.edit' }).click() + expect(onEdit).toHaveBeenCalledExactlyOnceWith(group) + expect(onView).not.toHaveBeenCalled() + + await more.click() + await screen.getByRole('menuitem', { name: 'common.operation.delete' }).click() + const confirmation = screen.getByRole('alertdialog') + await expect.element(confirmation).toBeVisible() + expect(onView).not.toHaveBeenCalled() + await confirmation.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(confirmation).not.toBeInTheDocument() + + await screen.getByRole('button', { name: 'Before policy' }).click() + await userEvent.tab() + await expect.element(policy).toHaveFocus() + expect(policy.element().matches(':focus-visible')).toBe(true) + await userEvent.keyboard('{Enter}') + expect(onView).toHaveBeenCalledExactlyOnceWith(group) + await userEvent.tab() + await expect.element(more).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect.element(screen.getByRole('menu')).toBeVisible() + expect(onView).toHaveBeenCalledTimes(1) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx index eb81ab256eeab7..c03aad767e299f 100644 --- a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -83,11 +83,14 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps } return ( -
    +
    + {/* Extend the native button's hit area across the row without nesting the actions. */} -
    +
    {canMutate && ( @@ -117,7 +120,7 @@ export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps ns: 'common', name: group.name, })} - className="data-popup-open:bg-state-base-hover" + className="pointer-events-auto data-popup-open:bg-state-base-hover" > From f956aa8c92bbae88624ad7fa3fc012d7adebae9d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 00:50:48 +0800 Subject: [PATCH 099/108] fix: respect Normal member access point read permissions --- oxlint-suppressions.json | 2 +- .../__tests__/built-in-access-points.spec.tsx | 58 +++- .../__tests__/built-in-permissions.spec.tsx | 257 ++++++++++++++++++ .../access-point/__tests__/mcp-card.spec.tsx | 3 +- .../built-in-access-points/index.tsx | 56 +++- .../built-in-access-points/mcp-card.tsx | 16 +- web/app/components/app/access-point/index.tsx | 4 + web/service/use-tools.ts | 33 +-- 8 files changed, 376 insertions(+), 53 deletions(-) create mode 100644 web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 156a7a824397ab..959677683c2944 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4471,7 +4471,7 @@ }, "web/service/use-tools.ts": { "@tanstack/query/prefer-query-options": { - "count": 15 + "count": 13 }, "no-restricted-imports": { "count": 1 diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 04406ecc119070..f1c3f55322f1f0 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { BuiltInAccessPoints } from '../built-in-access-points' const mocks = vi.hoisted(() => ({ @@ -111,6 +111,7 @@ describe('BuiltInAccessPoints', () => { it('renders the unpublished state across all access point cards', () => { render( { render( { enable_api: true, } render( - , + , ) expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() @@ -188,7 +196,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ triggerModeDisabled: false }), + expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.triggerCard).not.toHaveBeenCalled() expect(mocks.useAppWorkflow).toHaveBeenCalledWith('app-1', expect.any(Object)) @@ -217,7 +225,13 @@ describe('BuiltInAccessPoints', () => { } mocks.workflow = workflow render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability })) @@ -232,6 +246,7 @@ describe('BuiltInAccessPoints', () => { it('uses Access Point management for every requested built-in operation', () => { render( { it('highlights only the targeted built-in access point card', () => { render( { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith( @@ -290,7 +312,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ triggerModeDisabled: true }), + expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.triggerCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'available' }), @@ -308,7 +330,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) @@ -326,7 +354,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect( @@ -336,7 +370,13 @@ describe('BuiltInAccessPoints', () => { it('does not retry forbidden published workflow requests', () => { render( - , + , ) const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { diff --git a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx new file mode 100644 index 00000000000000..7235694beb6ad2 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx @@ -0,0 +1,257 @@ +import type { + AppDetailWithSite, + WorkflowResponse, + WorkflowTriggerResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { toast } from '@/app/notifications' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' +import { BuiltInAccessPoints } from '../built-in-access-points' + +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) +vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }) })) +vi.mock('@/app/components/app/overview/customize', () => ({ default: () => null })) +vi.mock('@/app/components/app/overview/settings', () => ({ default: () => null })) +vi.mock('@/app/components/app/overview/embedded', () => ({ default: () => null })) + +const inService = 'agentV2.agentDetail.access.status.inService' +const unavailable = 'deployments.health.ENVIRONMENT_STATUS_FAILED' +const unpublishedTitle = 'deployments.studio.accessPoint.noPublishedTitle' +const publishedPath = '/apps/app-1/workflows/publish' + +const getWebCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.webApp.title' }) +const getApiCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.serviceApi.title' }) +const getMcpCard = () => screen.getByRole('region', { name: 'tools.mcp.server.title' }) +const getTriggerCard = () => screen.getByRole('region', { name: 'navigation.settings.trigger' }) + +const publishedWorkflow: WorkflowResponse = { + id: 'published-workflow', + graph: { nodes: [{ data: { type: 'start' } }] }, + features: {}, + conversation_variables: [], + environment_variables: [], + rag_pipeline_variables: [], + created_at: 1, + updated_at: 1, + hash: 'published-hash', + marked_comment: '', + marked_name: '', + tool_published: false, + version: 'published', +} + +function renderAccessPoints({ + mode = 'workflow', + role = 'normal', + published = true, + triggers = [], + workflow = publishedWorkflow, + rbacEnabled = false, + canViewLayout = role === 'owner', + triggerResponse, +}: { + mode?: AppDetailWithSite['mode'] + role?: 'normal' | 'owner' + published?: boolean + triggers?: WorkflowTriggerResponse[] + workflow?: WorkflowResponse | null + rbacEnabled?: boolean + canViewLayout?: boolean + triggerResponse?: Promise +} = {}) { + const paths: string[] = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + const path = new URL(request.url).pathname.replace('/console/api', '') + if (request.method === 'POST' && path === '/apps/app-1/trigger-enable' && role === 'owner') { + const body = (await request.json()) as { trigger_id: string; enable_trigger: boolean } + triggers = triggers.map((trigger) => + trigger.id === body.trigger_id + ? { ...trigger, status: body.enable_trigger ? 'enabled' : 'disabled' } + : trigger, + ) + return Response.json({ result: 'success' }) + } + if (request.method !== 'GET') throw new Error(`Unexpected mutation: ${request.method} ${path}`) + paths.push(path) + if (path === publishedPath) { + return role === 'normal' && !rbacEnabled + ? Response.json( + { + code: 'forbidden', + message: "You don't have the permission to access the requested resource.", + }, + { status: 403 }, + ) + : Response.json(workflow) + } + if (path === '/apps/app-1/server') + return Response.json({ id: 'server-1', server_code: 'server-code', status: 'active' }) + if (path === '/apps/app-1/triggers') return triggerResponse ?? Response.json({ data: triggers }) + throw new Error(`Unexpected request: ${request.method} ${path}`) + }) + useAppStore.setState({ + appDetail: createAppDetailFixture({ + mode, + site: createAppSiteFixture(), + workflow: published ? { id: 'published-workflow' } : null, + }), + }) + renderWithConsoleQuery( + , + { + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD', rbac_enabled: rbacEnabled }, + }, + ) + return paths +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +it.each(['workflow', 'advanced-chat'] as const)( + 'shows a published %s to Normal members without requesting its graph', + async (mode) => { + const paths = renderAccessPoints({ mode }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument() + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength( + mode === 'workflow' ? 1 : 0, + ) + }, +) + +it('uses the app summary to identify an unpublished workflow for Normal members', async () => { + const paths = renderAccessPoints({ published: false }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(screen.getByText(unpublishedTitle)).toBeInTheDocument() + expect(within(getWebCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps published Owner access points available using the workflow graph', async () => { + const paths = renderAccessPoints({ role: 'owner' }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getTriggerCard()).getByText(unavailable)).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() +}) + +it.each(['disabled', 'unauthorized'])( + 'preserves trigger-only availability for a Normal member when its trigger is %s', + async (status) => { + const paths = renderAccessPoints({ + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status, + icon: '', + provider_name: '', + }, + ], + }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(within(getTriggerCard()).getByText('Published trigger')).toBeInTheDocument() + expect(within(getTriggerCard()).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'), + ).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + }, +) + +it('waits for published trigger metadata before showing Normal access point availability', async () => { + let finish!: (response: Response) => void + const triggerResponse = new Promise((resolve) => { + finish = resolve + }) + const paths = renderAccessPoints({ triggerResponse }) + + await waitFor(() => expect(paths).toContain('/apps/app-1/triggers')) + expect(getWebCard()).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + + finish(Response.json({ data: [] })) + await waitFor(() => expect(within(getWebCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(1) + expect(paths).not.toContain(publishedPath) +}) + +it('honors RBAC layout access even for a Normal workspace member', async () => { + const paths = renderAccessPoints({ rbacEnabled: true, canViewLayout: true }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps Owner MCP unavailable when the published graph has no Start node', async () => { + renderAccessPoints({ role: 'owner', workflow: { ...publishedWorkflow, graph: { nodes: [] } } }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(within(getMcpCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() +}) + +it('refreshes the shared trigger query after an Owner changes a published trigger', async () => { + const user = userEvent.setup() + const paths = renderAccessPoints({ + role: 'owner', + workflow: { ...publishedWorkflow, graph: { nodes: [{ data: { type: 'trigger-webhook' } }] } }, + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status: 'enabled', + icon: '', + provider_name: '', + }, + ], + }) + + const toggle = await within(getTriggerCard()).findByRole('switch', { name: 'Published trigger' }) + expect(toggle).toHaveAttribute('aria-checked', 'true') + await user.click(toggle) + + await waitFor(() => + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(2), + ) + expect(toggle).toHaveAttribute('aria-checked', 'false') + expect(toast.error).not.toHaveBeenCalled() +}) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index 4fd5ec63630054..1c1acc982adc44 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -118,9 +118,8 @@ function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: Publish , ) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index f0f3299f4ff827..0ac267998d7a33 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -3,13 +3,16 @@ import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { useSuspenseQuery } from '@tanstack/react-query' +import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useDocLink } from '@/context/i18n' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' +import { consoleQuery } from '@/service/console' import { useAppWorkflow } from '@/service/use-workflow' import { useAccessPointActions } from '../shared/use-access-point-actions' import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' @@ -20,6 +23,7 @@ import { WebAppAccessPointCard } from './web-app-card' type BuiltInAccessPointsProps = { appId: string + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -28,6 +32,7 @@ type BuiltInAccessPointsProps = { export function BuiltInAccessPoints({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -37,7 +42,13 @@ export function BuiltInAccessPoints({ const docLink = useDocLink() const appInfo = useAppStore((state) => state.appDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) + const workspace = useAtomValue(currentWorkspaceAtom) + const workspaceLoading = useAtomValue(currentWorkspaceLoadingAtom) + const canReadWorkflow = + canViewLayout && + (systemFeatures.rbac_enabled || + (!workspaceLoading && ['owner', 'admin', 'editor'].includes(workspace.role))) + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo) && canReadWorkflow) const { data: workflow, isError: workflowError, @@ -49,15 +60,34 @@ export function BuiltInAccessPoints({ return failureCount < 3 }, }) + const useWorkflowSummary = Boolean(appInfo && isAdvancedApp(appInfo) && !canReadWorkflow) + const needsPublishedTriggers = + useWorkflowSummary && appInfo?.mode === 'workflow' && Boolean(appInfo.workflow?.id) + const triggersQuery = useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + input: appInfo?.mode === 'workflow' ? { params: { app_id: appId } } : skipToken, + enabled: needsPublishedTriggers, + retry: false, + }), + ) const actions = useAccessPointActions(appId, canManageAccessPoint) if (!appInfo) return const workflowState = getPublishedWorkflowState(appInfo, workflow) - const builtInLoading = workflowState.requiresPublishedWorkflow && workflowLoading - const appCardsUnavailable = + const isUnpublished = useWorkflowSummary ? !appInfo.workflow?.id : workflowState.isUnpublished + const hasTriggerNode = useWorkflowSummary + ? needsPublishedTriggers && Boolean(triggersQuery.data?.data.length) + : workflowState.hasTriggerNode + const builtInLoading = workflowState.requiresPublishedWorkflow && - (workflowError || workflowState.isUnpublished || workflowState.hasTriggerNode) + (workspaceLoading || + (shouldFetchWorkflow && workflowLoading) || + (needsPublishedTriggers && triggersQuery.isPending)) + const builtInError = + (shouldFetchWorkflow && workflowError) || (needsPublishedTriggers && triggersQuery.isError) + const appCardsUnavailable = + workflowState.requiresPublishedWorkflow && (builtInError || isUnpublished || hasTriggerNode) const appCardAvailability = builtInLoading ? 'loading' : appCardsUnavailable @@ -65,13 +95,20 @@ export function BuiltInAccessPoints({ : 'available' const triggerAvailability = builtInLoading ? 'loading' - : workflowState.isUnpublished || !workflowState.hasTriggerNode + : builtInError || isUnpublished || !hasTriggerNode + ? 'unavailable' + : 'available' + const mcpAvailability = builtInLoading + ? 'loading' + : appCardsUnavailable || + (!workflowState.requiresPublishedWorkflow && !appInfo.model_config?.updated_at) || + (canReadWorkflow && workflowState.isWorkflowApp && !workflowState.hasStartNode) ? 'unavailable' : 'available' return (
    - {workflowState.isUnpublished && !workflowLoading && !workflowError && ( + {isUnpublished && !builtInLoading && !builtInError && (
    @@ -127,8 +164,7 @@ export function BuiltInAccessPoints({ appInfo={appInfo} canManageAccessPoint={canManageAccessPoint} workflow={workflow} - workflowLoading={workflowLoading} - triggerModeDisabled={workflowState.hasTriggerNode} + availability={mcpAvailability} highlighted={highlightedAccessPoint === 'mcp'} /> {workflowState.isWorkflowApp && ( @@ -141,7 +177,7 @@ export function BuiltInAccessPoints({ )}
    - {workflowState.hasTriggerNode && ( + {hasTriggerNode && (
    diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index 5e2c4f5ae08346..4366d788b69c53 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -2,6 +2,7 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' +import type { AccessPointAvailability } from '@/app/components/base/access-point/status' import { AlertDialog, AlertDialogCancelButton, @@ -26,7 +27,6 @@ import { useMCPServerDetail, useRefreshMCPServerCode, } from '@/service/use-tools' -import { AppModeEnum } from '@/types/app' import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' @@ -34,23 +34,20 @@ type MCPAccessPointCardProps = { appInfo: AppDetailWithSite canManageAccessPoint: boolean highlighted?: boolean - triggerModeDisabled: boolean + availability: AccessPointAvailability workflow: PublishedWorkflow - workflowLoading: boolean } export function MCPAccessPointCard({ appInfo, canManageAccessPoint, highlighted, - triggerModeDisabled, + availability, workflow, - workflowLoading, }: MCPAccessPointCardProps) { const { t } = useTranslation(['appOverview', 'common', 'deployments', 'tools']) const advancedApp = isAdvancedApp(appInfo) const basicApp = !advancedApp - const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW const [showServerModal, setShowServerModal] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) const basicConfig = appInfo.model_config @@ -84,11 +81,8 @@ export function MCPAccessPointCard({ ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` : '***********' const workflowNodes = getPublishedWorkflowNodes(workflow) - const missingStartNode = - workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start) - const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at - const loading = serverDetailLoading || (advancedApp && workflowLoading) - const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled) + const loading = serverDetailLoading || availability === 'loading' + const unavailable = !loading && availability === 'unavailable' const basicAppInputs = useMemo(() => { if (!basicApp || !basicAppInputForm) return [] diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index 0255ee9c946c46..2ca9b24cc15691 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -41,6 +41,7 @@ type AccessPointProps = { } type AccessPointContentProps = AccessPointProps & { + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -49,6 +50,7 @@ type AccessPointContentProps = AccessPointProps & { function AccessPointContent({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -147,6 +149,7 @@ function AccessPointContent({ {selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? ( { } // App Triggers API hooks -export type AppTrigger = { - id: string - trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' - title: string - node_id: string - provider_name: string - icon: string - status: 'enabled' | 'disabled' | 'unauthorized' - created_at: string - updated_at: string -} +export type AppTrigger = WorkflowTriggerResponse export const useAppTriggers = ( appId: string | undefined, options?: Omit, 'queryKey' | 'queryFn'>, ) => { - return useQuery<{ data: AppTrigger[] }>({ - queryKey: [NAME_SPACE, 'app-triggers', appId], - queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), - enabled: !!appId, - ...options, // Merge additional options while maintaining backward compatibility - }) + return useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + ...options, + input: appId ? { params: { app_id: appId } } : skipToken, + }), + ) } export const useInvalidateAppTriggers = () => { const queryClient = useQueryClient() return (appId: string) => { return queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'app-triggers', appId], + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: appId } }, + }), }) } } From 23e40a230a0a7b400baf18ad484f57edc31e1379 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 00:51:13 +0800 Subject: [PATCH 100/108] fix: read member access status from a dedicated control plane endpoint --- .../entities/network_access_group_entities.py | 11 ++ api/services/network_access_group_gateway.py | 21 +++ api/services/network_access_group_service.py | 20 ++- .../workspace/test_network_access_group.py | 150 ++++++++++++++++++ .../test_network_access_group_service.py | 32 +++- 5 files changed, 224 insertions(+), 10 deletions(-) diff --git a/api/services/entities/network_access_group_entities.py b/api/services/entities/network_access_group_entities.py index 589d51ab29a087..521c348f7bab84 100644 --- a/api/services/entities/network_access_group_entities.py +++ b/api/services/entities/network_access_group_entities.py @@ -66,6 +66,17 @@ class NetworkAccessAppConfig: available_access_points: tuple[NetworkAccessPoint, ...] = () +@dataclass(frozen=True) +class NetworkAccessAppStatusSnapshot: + """Control-plane entry state without policy identity or configuration.""" + + entitled: bool + configured: bool + enabled: bool + # Preserve legacy/future scopes so the service can project current App capabilities. + access_points: tuple[str, ...] + + @dataclass(frozen=True) class NetworkAccessAppStatus: """Public entry status without policy identity or configuration.""" diff --git a/api/services/network_access_group_gateway.py b/api/services/network_access_group_gateway.py index 3d634012fabb74..86fc0b811949ac 100644 --- a/api/services/network_access_group_gateway.py +++ b/api/services/network_access_group_gateway.py @@ -24,6 +24,7 @@ from services.billing_service import BillingService, _BillingHTTPStatusError from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessGroup, @@ -176,6 +177,14 @@ class _BindingUpdateWire(_WireModel): effective_enabled: StrictBool = False +class _AppStatusWire(_WireModel): + aliases = {"access_points": ("accessPoints",)} + entitled: StrictBool = False + configured: StrictBool = False + enabled: StrictBool = False + access_points: tuple[StrictStr, ...] = () + + class _DeletedWire(_WireModel): deleted: StrictBool = False @@ -322,6 +331,18 @@ def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) wire.binding.record() if wire.binding is not None else None, ) + @override + def get_app_status(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppStatusSnapshot: + wire = _decode( + _AppStatusWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group/status", + params={"actor_account_id": actor_account_id}, + ), + ) + return NetworkAccessAppStatusSnapshot(wire.entitled, wire.configured, wire.enabled, wire.access_points) + @override def update_app_binding( self, diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 940af7a457f88e..13b4e8b4717420 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -10,6 +10,7 @@ from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, NetworkAccessAppStatus, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -142,6 +143,10 @@ def delete_group( def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppConfig: ... + def get_app_status( + self, workspace_id: str, app_id: str, actor_account_id: str + ) -> NetworkAccessAppStatusSnapshot: ... + def update_app_binding( self, workspace_id: str, @@ -249,14 +254,15 @@ def get_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAcc def get_app_status(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppStatus: """Expose only the Access Point entry state to Normal workspace members.""" self._ensure_workspace_role(context, _APP_STATUS_ROLES) - config = self._read_app_binding(context, app_id=app_id) - binding = config.binding + app = self._get_manageable_app(context.active_workspace_id, app_id) + available_access_points = self._available_access_points(app.mode) + status = self._control_plane.get_app_status(context.active_workspace_id, app.id, context.account_id) return NetworkAccessAppStatus( - entitled=config.entitled, - configured=bool(binding and binding.group_id), - enabled=bool(binding and binding.enabled), - covered_count=len(binding.access_points) if binding else 0, - available_count=len(config.available_access_points), + entitled=self._effective_entitlement(context.active_workspace_id, status.entitled), + configured=status.configured, + enabled=status.enabled, + covered_count=len(set(status.access_points).intersection(available_access_points)), + available_count=len(available_access_points), ) def _read_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppConfig: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 0f87566c0ceada..3a73dc30fdec1f 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -272,6 +272,114 @@ def test_app_status_serializes_only_entry_state() -> None: service.get_app_status.assert_called_once_with(_request_context(), app_id=APP_ID) +@pytest.mark.parametrize("role", ["normal", "owner", "admin", "editor"]) +@pytest.mark.parametrize( + ("app_mode", "covered_count", "available_count"), + [ + ("workflow", 3, 4), + ("advanced-chat", 2, 3), + ("chat", 2, 3), + ("completion", 2, 3), + ("agent-chat", 2, 3), + ("agent", 1, 2), + ], +) +def test_app_status_uses_minimal_upstream_route_and_filters_scopes_for_current_mode( + role: str, app_mode: str, covered_count: int, available_count: int +) -> None: + response, requests = _typed_status_http_response( + { + "entitled": True, + "configured": True, + "enabled": True, + "accessPoints": ["webapp", "mcp", "trigger", "future_scope", "webapp"], + "group_id": GROUP_ID, + "allowed_cidrs": ["203.0.113.7/32"], + }, + role=role, + app_mode=app_mode, + ) + + assert response.status_code == 200 + assert response.get_json() == { + "entitled": True, + "configured": True, + "enabled": True, + "covered_count": covered_count, + "available_count": available_count, + } + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status" + assert dict(requests[0].url.params) == {"actor_account_id": ACCOUNT_ID} + assert requests[0].headers["Billing-Api-Secret-Key"] == "synthetic" + + +@pytest.mark.parametrize( + ("wire", "paid", "expected"), + [ + ({}, True, {"entitled": False, "configured": False, "enabled": False, "covered_count": 0}), + ({"entitled": True}, True, {"entitled": True, "configured": False, "enabled": False, "covered_count": 0}), + ( + {"entitled": True, "configured": True, "access_points": ["webapp"]}, + True, + {"entitled": True, "configured": True, "enabled": False, "covered_count": 1}, + ), + ( + {"entitled": True, "configured": True, "enabled": True, "access_points": ["webapp"]}, + False, + {"entitled": False, "configured": True, "enabled": True, "covered_count": 1}, + ), + ], +) +def test_app_status_preserves_saved_state_and_applies_local_entitlement( + wire: Mapping[str, object], paid: bool, expected: dict[str, object] +) -> None: + response, _requests = _typed_status_http_response(wire, paid=paid) + + assert response.status_code == 200 + assert response.get_json() == {**expected, "available_count": 4} + + +@pytest.mark.parametrize("role", ["dataset_operator", "unknown", None]) +def test_app_status_rejects_disallowed_roles_before_upstream_read(role: str | None) -> None: + response, requests = _typed_status_http_response({}, role=role) + + assert response.status_code == 403 + assert requests == [] + + +@pytest.mark.parametrize("upstream_status", [403, 404]) +def test_app_status_does_not_fall_back_to_full_binding_after_upstream_error(upstream_status: int) -> None: + response, requests = _typed_status_http_response({}, upstream_status=upstream_status) + + assert response.status_code == upstream_status + assert len(requests) == 1 + assert requests[0].url.path.endswith("/network-access-group/status") + + +@pytest.mark.parametrize("field", ["entitled", "configured", "enabled"]) +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) +def test_app_status_rejects_non_boolean_flags(field: str, value: object) -> None: + response, _requests = _typed_status_http_response({field: value}) + + assert response.status_code == 502 + + +@pytest.mark.parametrize( + "wire", + [ + {"access_points": "webapp"}, + {"access_points": [1]}, + {"access_points": ["webapp"], "accessPoints": ["mcp"]}, + ], +) +def test_app_status_rejects_malformed_or_conflicting_scopes(wire: Mapping[str, object]) -> None: + response, _requests = _typed_status_http_response(wire) + + assert response.status_code == 502 + + @pytest.mark.parametrize( ("status_code", "expected_exception"), [ @@ -818,3 +926,45 @@ def binding() -> dict[str, object]: with _application_services(service): return app.test_client().get("/binding") + + +def _typed_status_http_response( + wire: Mapping[str, object], + *, + role: str | None = "normal", + app_mode: str = "workflow", + paid: bool = True, + upstream_status: int = 200, +) -> tuple[TestResponse, list[httpx.Request]]: + app = Flask(__name__) + app.testing = True + apps, memberships, entitlement = MagicMock(), MagicMock(), MagicMock() + apps.get_manageable_app.return_value = NetworkAccessGroupAppRecord(APP_ID, app_mode, "Test", None, None, None) + memberships.get_account_role.return_value = role + entitlement.is_paid_plan.return_value = paid + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path != f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status": + return httpx.Response(403, json={"reason": "FORBIDDEN"}) + return httpx.Response(upstream_status, json=wire) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + gateway = NetworkAccessGroupGateway( + base_url="https://test.invalid", + fallback_base_url="", + secret_key="synthetic", + http_client=client, + ) + service = NetworkAccessGroupService( + control_plane=gateway, apps=apps, memberships=memberships, entitlement=entitlement + ) + api = AppNetworkAccessGroupStatusApi() + + @app.get("/status") + def status() -> dict[str, object]: + return unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + + with _application_services(service): + return app.test_client().get("/status"), requests diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py index 5cbdea8fd8dadf..faeef4644c6193 100644 --- a/api/tests/unit_tests/services/test_network_access_group_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -8,6 +8,7 @@ from machinery.context import RequestContext from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -128,6 +129,7 @@ def _harness(*, role: str | None = "owner", paid: bool = True, app_mode: str = " for method in (control_plane.get_group, control_plane.create_group, control_plane.update_group): method.return_value = _group() control_plane.get_app_binding.return_value = _config() + control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot(True, False, False, ()) control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate(_binding(), False) control_plane.delete_group.return_value = True control_plane.cleanup_app_binding.return_value = True @@ -342,8 +344,8 @@ def test_app_binding_rejects_disallowed_roles_before_app_lookup(operation: Mutat def test_normal_member_can_read_only_app_entry_status() -> None: harness = _harness(role="normal") - harness.control_plane.get_app_binding.return_value = _config( - _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "service_api")) + harness.control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot( + entitled=True, configured=True, enabled=True, access_points=("webapp", "service_api") ) status = harness.service.get_app_status(_context(), app_id=APP_ID) @@ -355,7 +357,8 @@ def test_normal_member_can_read_only_app_entry_status() -> None: assert status.available_count == 4 assert not hasattr(status, "group_id") assert not hasattr(status, "allowed_cidrs") - harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.get_app_status.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.get_app_binding.assert_not_called() @pytest.mark.parametrize("role", ["dataset_operator", None]) @@ -366,6 +369,29 @@ def test_app_entry_status_rejects_roles_without_app_access(role: str | None) -> harness.service.get_app_status(_context(), app_id=APP_ID) harness.apps.get_manageable_app.assert_not_called() + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_missing_tenant_scoped_app_before_upstream_read() -> None: + harness = _harness(role="normal") + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_unsupported_app_mode_before_upstream_read() -> None: + harness = _harness(role="normal", app_mode="unknown") + + with pytest.raises(NetworkAccessGroupUnsupportedAppModeError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.control_plane.get_app_status.assert_not_called() harness.control_plane.get_app_binding.assert_not_called() From 60ef8073e7d5af5d93ad02d2e375c52d5170782b Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 00:53:27 +0800 Subject: [PATCH 101/108] revert: withdraw SNP-951 changes pending backend coordination --- .../entities/network_access_group_entities.py | 11 - api/services/network_access_group_gateway.py | 21 -- api/services/network_access_group_service.py | 20 +- .../workspace/test_network_access_group.py | 150 ---------- .../test_network_access_group_service.py | 32 +-- oxlint-suppressions.json | 2 +- .../__tests__/built-in-access-points.spec.tsx | 58 +--- .../__tests__/built-in-permissions.spec.tsx | 257 ------------------ .../access-point/__tests__/mcp-card.spec.tsx | 3 +- .../built-in-access-points/index.tsx | 56 +--- .../built-in-access-points/mcp-card.tsx | 16 +- web/app/components/app/access-point/index.tsx | 4 - web/service/use-tools.ts | 33 ++- 13 files changed, 63 insertions(+), 600 deletions(-) delete mode 100644 web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx diff --git a/api/services/entities/network_access_group_entities.py b/api/services/entities/network_access_group_entities.py index 521c348f7bab84..589d51ab29a087 100644 --- a/api/services/entities/network_access_group_entities.py +++ b/api/services/entities/network_access_group_entities.py @@ -66,17 +66,6 @@ class NetworkAccessAppConfig: available_access_points: tuple[NetworkAccessPoint, ...] = () -@dataclass(frozen=True) -class NetworkAccessAppStatusSnapshot: - """Control-plane entry state without policy identity or configuration.""" - - entitled: bool - configured: bool - enabled: bool - # Preserve legacy/future scopes so the service can project current App capabilities. - access_points: tuple[str, ...] - - @dataclass(frozen=True) class NetworkAccessAppStatus: """Public entry status without policy identity or configuration.""" diff --git a/api/services/network_access_group_gateway.py b/api/services/network_access_group_gateway.py index 86fc0b811949ac..3d634012fabb74 100644 --- a/api/services/network_access_group_gateway.py +++ b/api/services/network_access_group_gateway.py @@ -24,7 +24,6 @@ from services.billing_service import BillingService, _BillingHTTPStatusError from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, - NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessGroup, @@ -177,14 +176,6 @@ class _BindingUpdateWire(_WireModel): effective_enabled: StrictBool = False -class _AppStatusWire(_WireModel): - aliases = {"access_points": ("accessPoints",)} - entitled: StrictBool = False - configured: StrictBool = False - enabled: StrictBool = False - access_points: tuple[StrictStr, ...] = () - - class _DeletedWire(_WireModel): deleted: StrictBool = False @@ -331,18 +322,6 @@ def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) wire.binding.record() if wire.binding is not None else None, ) - @override - def get_app_status(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppStatusSnapshot: - wire = _decode( - _AppStatusWire, - self._send_request( - "GET", - f"/tenants/{workspace_id}/apps/{app_id}/network-access-group/status", - params={"actor_account_id": actor_account_id}, - ), - ) - return NetworkAccessAppStatusSnapshot(wire.entitled, wire.configured, wire.enabled, wire.access_points) - @override def update_app_binding( self, diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 13b4e8b4717420..940af7a457f88e 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -10,7 +10,6 @@ from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, NetworkAccessAppStatus, - NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -143,10 +142,6 @@ def delete_group( def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppConfig: ... - def get_app_status( - self, workspace_id: str, app_id: str, actor_account_id: str - ) -> NetworkAccessAppStatusSnapshot: ... - def update_app_binding( self, workspace_id: str, @@ -254,15 +249,14 @@ def get_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAcc def get_app_status(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppStatus: """Expose only the Access Point entry state to Normal workspace members.""" self._ensure_workspace_role(context, _APP_STATUS_ROLES) - app = self._get_manageable_app(context.active_workspace_id, app_id) - available_access_points = self._available_access_points(app.mode) - status = self._control_plane.get_app_status(context.active_workspace_id, app.id, context.account_id) + config = self._read_app_binding(context, app_id=app_id) + binding = config.binding return NetworkAccessAppStatus( - entitled=self._effective_entitlement(context.active_workspace_id, status.entitled), - configured=status.configured, - enabled=status.enabled, - covered_count=len(set(status.access_points).intersection(available_access_points)), - available_count=len(available_access_points), + entitled=config.entitled, + configured=bool(binding and binding.group_id), + enabled=bool(binding and binding.enabled), + covered_count=len(binding.access_points) if binding else 0, + available_count=len(config.available_access_points), ) def _read_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppConfig: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 3a73dc30fdec1f..0f87566c0ceada 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -272,114 +272,6 @@ def test_app_status_serializes_only_entry_state() -> None: service.get_app_status.assert_called_once_with(_request_context(), app_id=APP_ID) -@pytest.mark.parametrize("role", ["normal", "owner", "admin", "editor"]) -@pytest.mark.parametrize( - ("app_mode", "covered_count", "available_count"), - [ - ("workflow", 3, 4), - ("advanced-chat", 2, 3), - ("chat", 2, 3), - ("completion", 2, 3), - ("agent-chat", 2, 3), - ("agent", 1, 2), - ], -) -def test_app_status_uses_minimal_upstream_route_and_filters_scopes_for_current_mode( - role: str, app_mode: str, covered_count: int, available_count: int -) -> None: - response, requests = _typed_status_http_response( - { - "entitled": True, - "configured": True, - "enabled": True, - "accessPoints": ["webapp", "mcp", "trigger", "future_scope", "webapp"], - "group_id": GROUP_ID, - "allowed_cidrs": ["203.0.113.7/32"], - }, - role=role, - app_mode=app_mode, - ) - - assert response.status_code == 200 - assert response.get_json() == { - "entitled": True, - "configured": True, - "enabled": True, - "covered_count": covered_count, - "available_count": available_count, - } - assert len(requests) == 1 - assert requests[0].method == "GET" - assert requests[0].url.path == f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status" - assert dict(requests[0].url.params) == {"actor_account_id": ACCOUNT_ID} - assert requests[0].headers["Billing-Api-Secret-Key"] == "synthetic" - - -@pytest.mark.parametrize( - ("wire", "paid", "expected"), - [ - ({}, True, {"entitled": False, "configured": False, "enabled": False, "covered_count": 0}), - ({"entitled": True}, True, {"entitled": True, "configured": False, "enabled": False, "covered_count": 0}), - ( - {"entitled": True, "configured": True, "access_points": ["webapp"]}, - True, - {"entitled": True, "configured": True, "enabled": False, "covered_count": 1}, - ), - ( - {"entitled": True, "configured": True, "enabled": True, "access_points": ["webapp"]}, - False, - {"entitled": False, "configured": True, "enabled": True, "covered_count": 1}, - ), - ], -) -def test_app_status_preserves_saved_state_and_applies_local_entitlement( - wire: Mapping[str, object], paid: bool, expected: dict[str, object] -) -> None: - response, _requests = _typed_status_http_response(wire, paid=paid) - - assert response.status_code == 200 - assert response.get_json() == {**expected, "available_count": 4} - - -@pytest.mark.parametrize("role", ["dataset_operator", "unknown", None]) -def test_app_status_rejects_disallowed_roles_before_upstream_read(role: str | None) -> None: - response, requests = _typed_status_http_response({}, role=role) - - assert response.status_code == 403 - assert requests == [] - - -@pytest.mark.parametrize("upstream_status", [403, 404]) -def test_app_status_does_not_fall_back_to_full_binding_after_upstream_error(upstream_status: int) -> None: - response, requests = _typed_status_http_response({}, upstream_status=upstream_status) - - assert response.status_code == upstream_status - assert len(requests) == 1 - assert requests[0].url.path.endswith("/network-access-group/status") - - -@pytest.mark.parametrize("field", ["entitled", "configured", "enabled"]) -@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) -def test_app_status_rejects_non_boolean_flags(field: str, value: object) -> None: - response, _requests = _typed_status_http_response({field: value}) - - assert response.status_code == 502 - - -@pytest.mark.parametrize( - "wire", - [ - {"access_points": "webapp"}, - {"access_points": [1]}, - {"access_points": ["webapp"], "accessPoints": ["mcp"]}, - ], -) -def test_app_status_rejects_malformed_or_conflicting_scopes(wire: Mapping[str, object]) -> None: - response, _requests = _typed_status_http_response(wire) - - assert response.status_code == 502 - - @pytest.mark.parametrize( ("status_code", "expected_exception"), [ @@ -926,45 +818,3 @@ def binding() -> dict[str, object]: with _application_services(service): return app.test_client().get("/binding") - - -def _typed_status_http_response( - wire: Mapping[str, object], - *, - role: str | None = "normal", - app_mode: str = "workflow", - paid: bool = True, - upstream_status: int = 200, -) -> tuple[TestResponse, list[httpx.Request]]: - app = Flask(__name__) - app.testing = True - apps, memberships, entitlement = MagicMock(), MagicMock(), MagicMock() - apps.get_manageable_app.return_value = NetworkAccessGroupAppRecord(APP_ID, app_mode, "Test", None, None, None) - memberships.get_account_role.return_value = role - entitlement.is_paid_plan.return_value = paid - requests: list[httpx.Request] = [] - - def respond(request: httpx.Request) -> httpx.Response: - requests.append(request) - if request.url.path != f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status": - return httpx.Response(403, json={"reason": "FORBIDDEN"}) - return httpx.Response(upstream_status, json=wire) - - with httpx.Client(transport=httpx.MockTransport(respond)) as client: - gateway = NetworkAccessGroupGateway( - base_url="https://test.invalid", - fallback_base_url="", - secret_key="synthetic", - http_client=client, - ) - service = NetworkAccessGroupService( - control_plane=gateway, apps=apps, memberships=memberships, entitlement=entitlement - ) - api = AppNetworkAccessGroupStatusApi() - - @app.get("/status") - def status() -> dict[str, object]: - return unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) - - with _application_services(service): - return app.test_client().get("/status"), requests diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py index faeef4644c6193..5cbdea8fd8dadf 100644 --- a/api/tests/unit_tests/services/test_network_access_group_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -8,7 +8,6 @@ from machinery.context import RequestContext from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, - NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -129,7 +128,6 @@ def _harness(*, role: str | None = "owner", paid: bool = True, app_mode: str = " for method in (control_plane.get_group, control_plane.create_group, control_plane.update_group): method.return_value = _group() control_plane.get_app_binding.return_value = _config() - control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot(True, False, False, ()) control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate(_binding(), False) control_plane.delete_group.return_value = True control_plane.cleanup_app_binding.return_value = True @@ -344,8 +342,8 @@ def test_app_binding_rejects_disallowed_roles_before_app_lookup(operation: Mutat def test_normal_member_can_read_only_app_entry_status() -> None: harness = _harness(role="normal") - harness.control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot( - entitled=True, configured=True, enabled=True, access_points=("webapp", "service_api") + harness.control_plane.get_app_binding.return_value = _config( + _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "service_api")) ) status = harness.service.get_app_status(_context(), app_id=APP_ID) @@ -357,8 +355,7 @@ def test_normal_member_can_read_only_app_entry_status() -> None: assert status.available_count == 4 assert not hasattr(status, "group_id") assert not hasattr(status, "allowed_cidrs") - harness.control_plane.get_app_status.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) - harness.control_plane.get_app_binding.assert_not_called() + harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) @pytest.mark.parametrize("role", ["dataset_operator", None]) @@ -369,29 +366,6 @@ def test_app_entry_status_rejects_roles_without_app_access(role: str | None) -> harness.service.get_app_status(_context(), app_id=APP_ID) harness.apps.get_manageable_app.assert_not_called() - harness.control_plane.get_app_status.assert_not_called() - harness.control_plane.get_app_binding.assert_not_called() - - -def test_app_status_rejects_missing_tenant_scoped_app_before_upstream_read() -> None: - harness = _harness(role="normal") - harness.apps.get_manageable_app.return_value = None - - with pytest.raises(NetworkAccessGroupAppNotFoundError): - harness.service.get_app_status(_context(), app_id=APP_ID) - - harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) - harness.control_plane.get_app_status.assert_not_called() - harness.control_plane.get_app_binding.assert_not_called() - - -def test_app_status_rejects_unsupported_app_mode_before_upstream_read() -> None: - harness = _harness(role="normal", app_mode="unknown") - - with pytest.raises(NetworkAccessGroupUnsupportedAppModeError): - harness.service.get_app_status(_context(), app_id=APP_ID) - - harness.control_plane.get_app_status.assert_not_called() harness.control_plane.get_app_binding.assert_not_called() diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 959677683c2944..156a7a824397ab 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4471,7 +4471,7 @@ }, "web/service/use-tools.ts": { "@tanstack/query/prefer-query-options": { - "count": 13 + "count": 15 }, "no-restricted-imports": { "count": 1 diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index f1c3f55322f1f0..04406ecc119070 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react' -import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { render } from '@/test/console/render' import { BuiltInAccessPoints } from '../built-in-access-points' const mocks = vi.hoisted(() => ({ @@ -111,7 +111,6 @@ describe('BuiltInAccessPoints', () => { it('renders the unpublished state across all access point cards', () => { render( { render( { enable_api: true, } render( - , + , ) expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() @@ -196,7 +188,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ availability: 'unavailable' }), + expect.objectContaining({ triggerModeDisabled: false }), ) expect(mocks.triggerCard).not.toHaveBeenCalled() expect(mocks.useAppWorkflow).toHaveBeenCalledWith('app-1', expect.any(Object)) @@ -225,13 +217,7 @@ describe('BuiltInAccessPoints', () => { } mocks.workflow = workflow render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability })) @@ -246,7 +232,6 @@ describe('BuiltInAccessPoints', () => { it('uses Access Point management for every requested built-in operation', () => { render( { it('highlights only the targeted built-in access point card', () => { render( { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith( @@ -312,7 +290,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ availability: 'unavailable' }), + expect.objectContaining({ triggerModeDisabled: true }), ) expect(mocks.triggerCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'available' }), @@ -330,13 +308,7 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) @@ -354,13 +326,7 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect( @@ -370,13 +336,7 @@ describe('BuiltInAccessPoints', () => { it('does not retry forbidden published workflow requests', () => { render( - , + , ) const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { diff --git a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx deleted file mode 100644 index 7235694beb6ad2..00000000000000 --- a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import type { - AppDetailWithSite, - WorkflowResponse, - WorkflowTriggerResponse, -} from '@dify/contracts/api/console/apps/types.gen' -import { screen, waitFor, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useStore as useAppStore } from '@/app/components/app/store' -import { toast } from '@/app/notifications' -import { renderWithConsoleQuery } from '@/test/console/query-data' -import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' -import { BuiltInAccessPoints } from '../built-in-access-points' - -vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) -vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }) })) -vi.mock('@/app/components/app/overview/customize', () => ({ default: () => null })) -vi.mock('@/app/components/app/overview/settings', () => ({ default: () => null })) -vi.mock('@/app/components/app/overview/embedded', () => ({ default: () => null })) - -const inService = 'agentV2.agentDetail.access.status.inService' -const unavailable = 'deployments.health.ENVIRONMENT_STATUS_FAILED' -const unpublishedTitle = 'deployments.studio.accessPoint.noPublishedTitle' -const publishedPath = '/apps/app-1/workflows/publish' - -const getWebCard = () => - screen.getByRole('region', { name: 'agentV2.agentDetail.access.webApp.title' }) -const getApiCard = () => - screen.getByRole('region', { name: 'agentV2.agentDetail.access.serviceApi.title' }) -const getMcpCard = () => screen.getByRole('region', { name: 'tools.mcp.server.title' }) -const getTriggerCard = () => screen.getByRole('region', { name: 'navigation.settings.trigger' }) - -const publishedWorkflow: WorkflowResponse = { - id: 'published-workflow', - graph: { nodes: [{ data: { type: 'start' } }] }, - features: {}, - conversation_variables: [], - environment_variables: [], - rag_pipeline_variables: [], - created_at: 1, - updated_at: 1, - hash: 'published-hash', - marked_comment: '', - marked_name: '', - tool_published: false, - version: 'published', -} - -function renderAccessPoints({ - mode = 'workflow', - role = 'normal', - published = true, - triggers = [], - workflow = publishedWorkflow, - rbacEnabled = false, - canViewLayout = role === 'owner', - triggerResponse, -}: { - mode?: AppDetailWithSite['mode'] - role?: 'normal' | 'owner' - published?: boolean - triggers?: WorkflowTriggerResponse[] - workflow?: WorkflowResponse | null - rbacEnabled?: boolean - canViewLayout?: boolean - triggerResponse?: Promise -} = {}) { - const paths: string[] = [] - vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { - const request = new Request(input, init) - const path = new URL(request.url).pathname.replace('/console/api', '') - if (request.method === 'POST' && path === '/apps/app-1/trigger-enable' && role === 'owner') { - const body = (await request.json()) as { trigger_id: string; enable_trigger: boolean } - triggers = triggers.map((trigger) => - trigger.id === body.trigger_id - ? { ...trigger, status: body.enable_trigger ? 'enabled' : 'disabled' } - : trigger, - ) - return Response.json({ result: 'success' }) - } - if (request.method !== 'GET') throw new Error(`Unexpected mutation: ${request.method} ${path}`) - paths.push(path) - if (path === publishedPath) { - return role === 'normal' && !rbacEnabled - ? Response.json( - { - code: 'forbidden', - message: "You don't have the permission to access the requested resource.", - }, - { status: 403 }, - ) - : Response.json(workflow) - } - if (path === '/apps/app-1/server') - return Response.json({ id: 'server-1', server_code: 'server-code', status: 'active' }) - if (path === '/apps/app-1/triggers') return triggerResponse ?? Response.json({ data: triggers }) - throw new Error(`Unexpected request: ${request.method} ${path}`) - }) - useAppStore.setState({ - appDetail: createAppDetailFixture({ - mode, - site: createAppSiteFixture(), - workflow: published ? { id: 'published-workflow' } : null, - }), - }) - renderWithConsoleQuery( - , - { - currentWorkspace: { role }, - systemFeatures: { deployment_edition: 'CLOUD', rbac_enabled: rbacEnabled }, - }, - ) - return paths -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -it.each(['workflow', 'advanced-chat'] as const)( - 'shows a published %s to Normal members without requesting its graph', - async (mode) => { - const paths = renderAccessPoints({ mode }) - - await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) - expect(paths).not.toContain(publishedPath) - expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() - expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() - expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument() - expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() - expect(toast.error).not.toHaveBeenCalled() - for (const card of [getWebCard(), getApiCard(), getMcpCard()]) - expect(within(card).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') - expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength( - mode === 'workflow' ? 1 : 0, - ) - }, -) - -it('uses the app summary to identify an unpublished workflow for Normal members', async () => { - const paths = renderAccessPoints({ published: false }) - - await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) - expect(paths).not.toContain(publishedPath) - expect(screen.getByText(unpublishedTitle)).toBeInTheDocument() - expect(within(getWebCard()).getAllByText(unavailable).length).toBeGreaterThan(0) - expect(toast.error).not.toHaveBeenCalled() -}) - -it('keeps published Owner access points available using the workflow graph', async () => { - const paths = renderAccessPoints({ role: 'owner' }) - - await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) - expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) - expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() - expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() - expect(within(getTriggerCard()).getByText(unavailable)).toBeInTheDocument() - expect(toast.error).not.toHaveBeenCalled() -}) - -it.each(['disabled', 'unauthorized'])( - 'preserves trigger-only availability for a Normal member when its trigger is %s', - async (status) => { - const paths = renderAccessPoints({ - triggers: [ - { - id: 'trigger-1', - node_id: 'trigger-node', - title: 'Published trigger', - trigger_type: 'trigger-webhook', - status, - icon: '', - provider_name: '', - }, - ], - }) - - await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) - expect(paths).not.toContain(publishedPath) - for (const card of [getWebCard(), getApiCard(), getMcpCard()]) - expect(within(card).getAllByText(unavailable).length).toBeGreaterThan(0) - expect(within(getTriggerCard()).getByText('Published trigger')).toBeInTheDocument() - expect(within(getTriggerCard()).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') - expect( - screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'), - ).toBeInTheDocument() - expect(toast.error).not.toHaveBeenCalled() - }, -) - -it('waits for published trigger metadata before showing Normal access point availability', async () => { - let finish!: (response: Response) => void - const triggerResponse = new Promise((resolve) => { - finish = resolve - }) - const paths = renderAccessPoints({ triggerResponse }) - - await waitFor(() => expect(paths).toContain('/apps/app-1/triggers')) - expect(getWebCard()).toHaveAttribute('aria-busy', 'true') - expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() - - finish(Response.json({ data: [] })) - await waitFor(() => expect(within(getWebCard()).getByText(inService)).toBeInTheDocument()) - expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(1) - expect(paths).not.toContain(publishedPath) -}) - -it('honors RBAC layout access even for a Normal workspace member', async () => { - const paths = renderAccessPoints({ rbacEnabled: true, canViewLayout: true }) - - await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) - expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) - expect(toast.error).not.toHaveBeenCalled() -}) - -it('keeps Owner MCP unavailable when the published graph has no Start node', async () => { - renderAccessPoints({ role: 'owner', workflow: { ...publishedWorkflow, graph: { nodes: [] } } }) - - await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) - expect(within(getMcpCard()).getAllByText(unavailable).length).toBeGreaterThan(0) - expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() -}) - -it('refreshes the shared trigger query after an Owner changes a published trigger', async () => { - const user = userEvent.setup() - const paths = renderAccessPoints({ - role: 'owner', - workflow: { ...publishedWorkflow, graph: { nodes: [{ data: { type: 'trigger-webhook' } }] } }, - triggers: [ - { - id: 'trigger-1', - node_id: 'trigger-node', - title: 'Published trigger', - trigger_type: 'trigger-webhook', - status: 'enabled', - icon: '', - provider_name: '', - }, - ], - }) - - const toggle = await within(getTriggerCard()).findByRole('switch', { name: 'Published trigger' }) - expect(toggle).toHaveAttribute('aria-checked', 'true') - await user.click(toggle) - - await waitFor(() => - expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(2), - ) - expect(toggle).toHaveAttribute('aria-checked', 'false') - expect(toast.error).not.toHaveBeenCalled() -}) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index 1c1acc982adc44..4fd5ec63630054 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -118,8 +118,9 @@ function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: Publish , ) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index 0ac267998d7a33..f0f3299f4ff827 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -3,16 +3,13 @@ import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' +import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useDocLink } from '@/context/i18n' -import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' -import { consoleQuery } from '@/service/console' import { useAppWorkflow } from '@/service/use-workflow' import { useAccessPointActions } from '../shared/use-access-point-actions' import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' @@ -23,7 +20,6 @@ import { WebAppAccessPointCard } from './web-app-card' type BuiltInAccessPointsProps = { appId: string - canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -32,7 +28,6 @@ type BuiltInAccessPointsProps = { export function BuiltInAccessPoints({ appId, - canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -42,13 +37,7 @@ export function BuiltInAccessPoints({ const docLink = useDocLink() const appInfo = useAppStore((state) => state.appDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const workspace = useAtomValue(currentWorkspaceAtom) - const workspaceLoading = useAtomValue(currentWorkspaceLoadingAtom) - const canReadWorkflow = - canViewLayout && - (systemFeatures.rbac_enabled || - (!workspaceLoading && ['owner', 'admin', 'editor'].includes(workspace.role))) - const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo) && canReadWorkflow) + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) const { data: workflow, isError: workflowError, @@ -60,34 +49,15 @@ export function BuiltInAccessPoints({ return failureCount < 3 }, }) - const useWorkflowSummary = Boolean(appInfo && isAdvancedApp(appInfo) && !canReadWorkflow) - const needsPublishedTriggers = - useWorkflowSummary && appInfo?.mode === 'workflow' && Boolean(appInfo.workflow?.id) - const triggersQuery = useQuery( - consoleQuery.apps.byAppId.triggers.get.queryOptions({ - input: appInfo?.mode === 'workflow' ? { params: { app_id: appId } } : skipToken, - enabled: needsPublishedTriggers, - retry: false, - }), - ) const actions = useAccessPointActions(appId, canManageAccessPoint) if (!appInfo) return const workflowState = getPublishedWorkflowState(appInfo, workflow) - const isUnpublished = useWorkflowSummary ? !appInfo.workflow?.id : workflowState.isUnpublished - const hasTriggerNode = useWorkflowSummary - ? needsPublishedTriggers && Boolean(triggersQuery.data?.data.length) - : workflowState.hasTriggerNode - const builtInLoading = - workflowState.requiresPublishedWorkflow && - (workspaceLoading || - (shouldFetchWorkflow && workflowLoading) || - (needsPublishedTriggers && triggersQuery.isPending)) - const builtInError = - (shouldFetchWorkflow && workflowError) || (needsPublishedTriggers && triggersQuery.isError) + const builtInLoading = workflowState.requiresPublishedWorkflow && workflowLoading const appCardsUnavailable = - workflowState.requiresPublishedWorkflow && (builtInError || isUnpublished || hasTriggerNode) + workflowState.requiresPublishedWorkflow && + (workflowError || workflowState.isUnpublished || workflowState.hasTriggerNode) const appCardAvailability = builtInLoading ? 'loading' : appCardsUnavailable @@ -95,20 +65,13 @@ export function BuiltInAccessPoints({ : 'available' const triggerAvailability = builtInLoading ? 'loading' - : builtInError || isUnpublished || !hasTriggerNode - ? 'unavailable' - : 'available' - const mcpAvailability = builtInLoading - ? 'loading' - : appCardsUnavailable || - (!workflowState.requiresPublishedWorkflow && !appInfo.model_config?.updated_at) || - (canReadWorkflow && workflowState.isWorkflowApp && !workflowState.hasStartNode) + : workflowState.isUnpublished || !workflowState.hasTriggerNode ? 'unavailable' : 'available' return (
    - {isUnpublished && !builtInLoading && !builtInError && ( + {workflowState.isUnpublished && !workflowLoading && !workflowError && (
    @@ -164,7 +127,8 @@ export function BuiltInAccessPoints({ appInfo={appInfo} canManageAccessPoint={canManageAccessPoint} workflow={workflow} - availability={mcpAvailability} + workflowLoading={workflowLoading} + triggerModeDisabled={workflowState.hasTriggerNode} highlighted={highlightedAccessPoint === 'mcp'} /> {workflowState.isWorkflowApp && ( @@ -177,7 +141,7 @@ export function BuiltInAccessPoints({ )}
    - {hasTriggerNode && ( + {workflowState.hasTriggerNode && (
    diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index 4366d788b69c53..5e2c4f5ae08346 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -2,7 +2,6 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' -import type { AccessPointAvailability } from '@/app/components/base/access-point/status' import { AlertDialog, AlertDialogCancelButton, @@ -27,6 +26,7 @@ import { useMCPServerDetail, useRefreshMCPServerCode, } from '@/service/use-tools' +import { AppModeEnum } from '@/types/app' import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' @@ -34,20 +34,23 @@ type MCPAccessPointCardProps = { appInfo: AppDetailWithSite canManageAccessPoint: boolean highlighted?: boolean - availability: AccessPointAvailability + triggerModeDisabled: boolean workflow: PublishedWorkflow + workflowLoading: boolean } export function MCPAccessPointCard({ appInfo, canManageAccessPoint, highlighted, - availability, + triggerModeDisabled, workflow, + workflowLoading, }: MCPAccessPointCardProps) { const { t } = useTranslation(['appOverview', 'common', 'deployments', 'tools']) const advancedApp = isAdvancedApp(appInfo) const basicApp = !advancedApp + const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW const [showServerModal, setShowServerModal] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) const basicConfig = appInfo.model_config @@ -81,8 +84,11 @@ export function MCPAccessPointCard({ ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` : '***********' const workflowNodes = getPublishedWorkflowNodes(workflow) - const loading = serverDetailLoading || availability === 'loading' - const unavailable = !loading && availability === 'unavailable' + const missingStartNode = + workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start) + const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at + const loading = serverDetailLoading || (advancedApp && workflowLoading) + const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled) const basicAppInputs = useMemo(() => { if (!basicApp || !basicAppInputForm) return [] diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index 2ca9b24cc15691..0255ee9c946c46 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -41,7 +41,6 @@ type AccessPointProps = { } type AccessPointContentProps = AccessPointProps & { - canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -50,7 +49,6 @@ type AccessPointContentProps = AccessPointProps & { function AccessPointContent({ appId, - canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -149,7 +147,6 @@ function AccessPointContent({ {selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? ( { } // App Triggers API hooks -export type AppTrigger = WorkflowTriggerResponse +export type AppTrigger = { + id: string + trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' + title: string + node_id: string + provider_name: string + icon: string + status: 'enabled' | 'disabled' | 'unauthorized' + created_at: string + updated_at: string +} export const useAppTriggers = ( appId: string | undefined, options?: Omit, 'queryKey' | 'queryFn'>, ) => { - return useQuery( - consoleQuery.apps.byAppId.triggers.get.queryOptions({ - ...options, - input: appId ? { params: { app_id: appId } } : skipToken, - }), - ) + return useQuery<{ data: AppTrigger[] }>({ + queryKey: [NAME_SPACE, 'app-triggers', appId], + queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), + enabled: !!appId, + ...options, // Merge additional options while maintaining backward compatibility + }) } export const useInvalidateAppTriggers = () => { const queryClient = useQueryClient() return (appId: string) => { return queryClient.invalidateQueries({ - queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ - input: { params: { app_id: appId } }, - }), + queryKey: [NAME_SPACE, 'app-triggers', appId], }) } } From 2eb2029f162730b2f345b60a44259a39d3d029fa Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 10:11:10 +0800 Subject: [PATCH 102/108] fix(web): respect Normal member access point permissions (SNP-951) --- oxlint-suppressions.json | 2 +- .../__tests__/built-in-access-points.spec.tsx | 58 +++- .../__tests__/built-in-permissions.spec.tsx | 257 ++++++++++++++++++ .../access-point/__tests__/mcp-card.spec.tsx | 3 +- .../built-in-access-points/index.tsx | 56 +++- .../built-in-access-points/mcp-card.tsx | 16 +- web/app/components/app/access-point/index.tsx | 4 + web/service/use-tools.ts | 33 +-- 8 files changed, 376 insertions(+), 53 deletions(-) create mode 100644 web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 156a7a824397ab..959677683c2944 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4471,7 +4471,7 @@ }, "web/service/use-tools.ts": { "@tanstack/query/prefer-query-options": { - "count": 15 + "count": 13 }, "no-restricted-imports": { "count": 1 diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 04406ecc119070..f1c3f55322f1f0 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react' -import { render } from '@/test/console/render' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { BuiltInAccessPoints } from '../built-in-access-points' const mocks = vi.hoisted(() => ({ @@ -111,6 +111,7 @@ describe('BuiltInAccessPoints', () => { it('renders the unpublished state across all access point cards', () => { render( { render( { enable_api: true, } render( - , + , ) expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() @@ -188,7 +196,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ triggerModeDisabled: false }), + expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.triggerCard).not.toHaveBeenCalled() expect(mocks.useAppWorkflow).toHaveBeenCalledWith('app-1', expect.any(Object)) @@ -217,7 +225,13 @@ describe('BuiltInAccessPoints', () => { } mocks.workflow = workflow render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability })) @@ -232,6 +246,7 @@ describe('BuiltInAccessPoints', () => { it('uses Access Point management for every requested built-in operation', () => { render( { it('highlights only the targeted built-in access point card', () => { render( { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith( @@ -290,7 +312,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ triggerModeDisabled: true }), + expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.triggerCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'available' }), @@ -308,7 +330,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) @@ -326,7 +354,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect( @@ -336,7 +370,13 @@ describe('BuiltInAccessPoints', () => { it('does not retry forbidden published workflow requests', () => { render( - , + , ) const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { diff --git a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx new file mode 100644 index 00000000000000..7235694beb6ad2 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx @@ -0,0 +1,257 @@ +import type { + AppDetailWithSite, + WorkflowResponse, + WorkflowTriggerResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { toast } from '@/app/notifications' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' +import { BuiltInAccessPoints } from '../built-in-access-points' + +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) +vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }) })) +vi.mock('@/app/components/app/overview/customize', () => ({ default: () => null })) +vi.mock('@/app/components/app/overview/settings', () => ({ default: () => null })) +vi.mock('@/app/components/app/overview/embedded', () => ({ default: () => null })) + +const inService = 'agentV2.agentDetail.access.status.inService' +const unavailable = 'deployments.health.ENVIRONMENT_STATUS_FAILED' +const unpublishedTitle = 'deployments.studio.accessPoint.noPublishedTitle' +const publishedPath = '/apps/app-1/workflows/publish' + +const getWebCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.webApp.title' }) +const getApiCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.serviceApi.title' }) +const getMcpCard = () => screen.getByRole('region', { name: 'tools.mcp.server.title' }) +const getTriggerCard = () => screen.getByRole('region', { name: 'navigation.settings.trigger' }) + +const publishedWorkflow: WorkflowResponse = { + id: 'published-workflow', + graph: { nodes: [{ data: { type: 'start' } }] }, + features: {}, + conversation_variables: [], + environment_variables: [], + rag_pipeline_variables: [], + created_at: 1, + updated_at: 1, + hash: 'published-hash', + marked_comment: '', + marked_name: '', + tool_published: false, + version: 'published', +} + +function renderAccessPoints({ + mode = 'workflow', + role = 'normal', + published = true, + triggers = [], + workflow = publishedWorkflow, + rbacEnabled = false, + canViewLayout = role === 'owner', + triggerResponse, +}: { + mode?: AppDetailWithSite['mode'] + role?: 'normal' | 'owner' + published?: boolean + triggers?: WorkflowTriggerResponse[] + workflow?: WorkflowResponse | null + rbacEnabled?: boolean + canViewLayout?: boolean + triggerResponse?: Promise +} = {}) { + const paths: string[] = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + const path = new URL(request.url).pathname.replace('/console/api', '') + if (request.method === 'POST' && path === '/apps/app-1/trigger-enable' && role === 'owner') { + const body = (await request.json()) as { trigger_id: string; enable_trigger: boolean } + triggers = triggers.map((trigger) => + trigger.id === body.trigger_id + ? { ...trigger, status: body.enable_trigger ? 'enabled' : 'disabled' } + : trigger, + ) + return Response.json({ result: 'success' }) + } + if (request.method !== 'GET') throw new Error(`Unexpected mutation: ${request.method} ${path}`) + paths.push(path) + if (path === publishedPath) { + return role === 'normal' && !rbacEnabled + ? Response.json( + { + code: 'forbidden', + message: "You don't have the permission to access the requested resource.", + }, + { status: 403 }, + ) + : Response.json(workflow) + } + if (path === '/apps/app-1/server') + return Response.json({ id: 'server-1', server_code: 'server-code', status: 'active' }) + if (path === '/apps/app-1/triggers') return triggerResponse ?? Response.json({ data: triggers }) + throw new Error(`Unexpected request: ${request.method} ${path}`) + }) + useAppStore.setState({ + appDetail: createAppDetailFixture({ + mode, + site: createAppSiteFixture(), + workflow: published ? { id: 'published-workflow' } : null, + }), + }) + renderWithConsoleQuery( + , + { + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD', rbac_enabled: rbacEnabled }, + }, + ) + return paths +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +it.each(['workflow', 'advanced-chat'] as const)( + 'shows a published %s to Normal members without requesting its graph', + async (mode) => { + const paths = renderAccessPoints({ mode }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument() + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength( + mode === 'workflow' ? 1 : 0, + ) + }, +) + +it('uses the app summary to identify an unpublished workflow for Normal members', async () => { + const paths = renderAccessPoints({ published: false }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(screen.getByText(unpublishedTitle)).toBeInTheDocument() + expect(within(getWebCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps published Owner access points available using the workflow graph', async () => { + const paths = renderAccessPoints({ role: 'owner' }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getTriggerCard()).getByText(unavailable)).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() +}) + +it.each(['disabled', 'unauthorized'])( + 'preserves trigger-only availability for a Normal member when its trigger is %s', + async (status) => { + const paths = renderAccessPoints({ + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status, + icon: '', + provider_name: '', + }, + ], + }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(within(getTriggerCard()).getByText('Published trigger')).toBeInTheDocument() + expect(within(getTriggerCard()).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'), + ).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + }, +) + +it('waits for published trigger metadata before showing Normal access point availability', async () => { + let finish!: (response: Response) => void + const triggerResponse = new Promise((resolve) => { + finish = resolve + }) + const paths = renderAccessPoints({ triggerResponse }) + + await waitFor(() => expect(paths).toContain('/apps/app-1/triggers')) + expect(getWebCard()).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + + finish(Response.json({ data: [] })) + await waitFor(() => expect(within(getWebCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(1) + expect(paths).not.toContain(publishedPath) +}) + +it('honors RBAC layout access even for a Normal workspace member', async () => { + const paths = renderAccessPoints({ rbacEnabled: true, canViewLayout: true }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps Owner MCP unavailable when the published graph has no Start node', async () => { + renderAccessPoints({ role: 'owner', workflow: { ...publishedWorkflow, graph: { nodes: [] } } }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(within(getMcpCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() +}) + +it('refreshes the shared trigger query after an Owner changes a published trigger', async () => { + const user = userEvent.setup() + const paths = renderAccessPoints({ + role: 'owner', + workflow: { ...publishedWorkflow, graph: { nodes: [{ data: { type: 'trigger-webhook' } }] } }, + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status: 'enabled', + icon: '', + provider_name: '', + }, + ], + }) + + const toggle = await within(getTriggerCard()).findByRole('switch', { name: 'Published trigger' }) + expect(toggle).toHaveAttribute('aria-checked', 'true') + await user.click(toggle) + + await waitFor(() => + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(2), + ) + expect(toggle).toHaveAttribute('aria-checked', 'false') + expect(toast.error).not.toHaveBeenCalled() +}) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index 4fd5ec63630054..1c1acc982adc44 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -118,9 +118,8 @@ function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: Publish , ) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index f0f3299f4ff827..0ac267998d7a33 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -3,13 +3,16 @@ import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { useSuspenseQuery } from '@tanstack/react-query' +import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useDocLink } from '@/context/i18n' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' +import { consoleQuery } from '@/service/console' import { useAppWorkflow } from '@/service/use-workflow' import { useAccessPointActions } from '../shared/use-access-point-actions' import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' @@ -20,6 +23,7 @@ import { WebAppAccessPointCard } from './web-app-card' type BuiltInAccessPointsProps = { appId: string + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -28,6 +32,7 @@ type BuiltInAccessPointsProps = { export function BuiltInAccessPoints({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -37,7 +42,13 @@ export function BuiltInAccessPoints({ const docLink = useDocLink() const appInfo = useAppStore((state) => state.appDetail) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) + const workspace = useAtomValue(currentWorkspaceAtom) + const workspaceLoading = useAtomValue(currentWorkspaceLoadingAtom) + const canReadWorkflow = + canViewLayout && + (systemFeatures.rbac_enabled || + (!workspaceLoading && ['owner', 'admin', 'editor'].includes(workspace.role))) + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo) && canReadWorkflow) const { data: workflow, isError: workflowError, @@ -49,15 +60,34 @@ export function BuiltInAccessPoints({ return failureCount < 3 }, }) + const useWorkflowSummary = Boolean(appInfo && isAdvancedApp(appInfo) && !canReadWorkflow) + const needsPublishedTriggers = + useWorkflowSummary && appInfo?.mode === 'workflow' && Boolean(appInfo.workflow?.id) + const triggersQuery = useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + input: appInfo?.mode === 'workflow' ? { params: { app_id: appId } } : skipToken, + enabled: needsPublishedTriggers, + retry: false, + }), + ) const actions = useAccessPointActions(appId, canManageAccessPoint) if (!appInfo) return const workflowState = getPublishedWorkflowState(appInfo, workflow) - const builtInLoading = workflowState.requiresPublishedWorkflow && workflowLoading - const appCardsUnavailable = + const isUnpublished = useWorkflowSummary ? !appInfo.workflow?.id : workflowState.isUnpublished + const hasTriggerNode = useWorkflowSummary + ? needsPublishedTriggers && Boolean(triggersQuery.data?.data.length) + : workflowState.hasTriggerNode + const builtInLoading = workflowState.requiresPublishedWorkflow && - (workflowError || workflowState.isUnpublished || workflowState.hasTriggerNode) + (workspaceLoading || + (shouldFetchWorkflow && workflowLoading) || + (needsPublishedTriggers && triggersQuery.isPending)) + const builtInError = + (shouldFetchWorkflow && workflowError) || (needsPublishedTriggers && triggersQuery.isError) + const appCardsUnavailable = + workflowState.requiresPublishedWorkflow && (builtInError || isUnpublished || hasTriggerNode) const appCardAvailability = builtInLoading ? 'loading' : appCardsUnavailable @@ -65,13 +95,20 @@ export function BuiltInAccessPoints({ : 'available' const triggerAvailability = builtInLoading ? 'loading' - : workflowState.isUnpublished || !workflowState.hasTriggerNode + : builtInError || isUnpublished || !hasTriggerNode + ? 'unavailable' + : 'available' + const mcpAvailability = builtInLoading + ? 'loading' + : appCardsUnavailable || + (!workflowState.requiresPublishedWorkflow && !appInfo.model_config?.updated_at) || + (canReadWorkflow && workflowState.isWorkflowApp && !workflowState.hasStartNode) ? 'unavailable' : 'available' return (
    - {workflowState.isUnpublished && !workflowLoading && !workflowError && ( + {isUnpublished && !builtInLoading && !builtInError && (
    @@ -127,8 +164,7 @@ export function BuiltInAccessPoints({ appInfo={appInfo} canManageAccessPoint={canManageAccessPoint} workflow={workflow} - workflowLoading={workflowLoading} - triggerModeDisabled={workflowState.hasTriggerNode} + availability={mcpAvailability} highlighted={highlightedAccessPoint === 'mcp'} /> {workflowState.isWorkflowApp && ( @@ -141,7 +177,7 @@ export function BuiltInAccessPoints({ )}
    - {workflowState.hasTriggerNode && ( + {hasTriggerNode && (
    diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index 5e2c4f5ae08346..4366d788b69c53 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -2,6 +2,7 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' +import type { AccessPointAvailability } from '@/app/components/base/access-point/status' import { AlertDialog, AlertDialogCancelButton, @@ -26,7 +27,6 @@ import { useMCPServerDetail, useRefreshMCPServerCode, } from '@/service/use-tools' -import { AppModeEnum } from '@/types/app' import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' @@ -34,23 +34,20 @@ type MCPAccessPointCardProps = { appInfo: AppDetailWithSite canManageAccessPoint: boolean highlighted?: boolean - triggerModeDisabled: boolean + availability: AccessPointAvailability workflow: PublishedWorkflow - workflowLoading: boolean } export function MCPAccessPointCard({ appInfo, canManageAccessPoint, highlighted, - triggerModeDisabled, + availability, workflow, - workflowLoading, }: MCPAccessPointCardProps) { const { t } = useTranslation(['appOverview', 'common', 'deployments', 'tools']) const advancedApp = isAdvancedApp(appInfo) const basicApp = !advancedApp - const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW const [showServerModal, setShowServerModal] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) const basicConfig = appInfo.model_config @@ -84,11 +81,8 @@ export function MCPAccessPointCard({ ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` : '***********' const workflowNodes = getPublishedWorkflowNodes(workflow) - const missingStartNode = - workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start) - const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at - const loading = serverDetailLoading || (advancedApp && workflowLoading) - const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled) + const loading = serverDetailLoading || availability === 'loading' + const unavailable = !loading && availability === 'unavailable' const basicAppInputs = useMemo(() => { if (!basicApp || !basicAppInputForm) return [] diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index 0255ee9c946c46..2ca9b24cc15691 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -41,6 +41,7 @@ type AccessPointProps = { } type AccessPointContentProps = AccessPointProps & { + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -49,6 +50,7 @@ type AccessPointContentProps = AccessPointProps & { function AccessPointContent({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -147,6 +149,7 @@ function AccessPointContent({ {selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? ( { } // App Triggers API hooks -export type AppTrigger = { - id: string - trigger_type: 'trigger-webhook' | 'trigger-schedule' | 'trigger-plugin' - title: string - node_id: string - provider_name: string - icon: string - status: 'enabled' | 'disabled' | 'unauthorized' - created_at: string - updated_at: string -} +export type AppTrigger = WorkflowTriggerResponse export const useAppTriggers = ( appId: string | undefined, options?: Omit, 'queryKey' | 'queryFn'>, ) => { - return useQuery<{ data: AppTrigger[] }>({ - queryKey: [NAME_SPACE, 'app-triggers', appId], - queryFn: () => get<{ data: AppTrigger[] }>(`/apps/${appId}/triggers`), - enabled: !!appId, - ...options, // Merge additional options while maintaining backward compatibility - }) + return useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + ...options, + input: appId ? { params: { app_id: appId } } : skipToken, + }), + ) } export const useInvalidateAppTriggers = () => { const queryClient = useQueryClient() return (appId: string) => { return queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'app-triggers', appId], + queryKey: consoleQuery.apps.byAppId.triggers.get.queryKey({ + input: { params: { app_id: appId } }, + }), }) } } From 1c72f7e85bc85cecdb18f2726092875b02ad6b0a Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Tue, 13 Oct 2026 10:14:24 +0800 Subject: [PATCH 103/108] test(web): support deploy branch customize dialog export --- .../app/access-point/__tests__/built-in-permissions.spec.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx index 7235694beb6ad2..032735254b0a05 100644 --- a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx @@ -14,7 +14,10 @@ import { BuiltInAccessPoints } from '../built-in-access-points' vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }) })) -vi.mock('@/app/components/app/overview/customize', () => ({ default: () => null })) +vi.mock('@/app/components/app/overview/customize', () => ({ + default: () => null, + CustomizeDialog: () => null, +})) vi.mock('@/app/components/app/overview/settings', () => ({ default: () => null })) vi.mock('@/app/components/app/overview/embedded', () => ({ default: () => null })) From 847d9365042f07f858d86b58e74cb8277087e0e6 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 13 Oct 2026 12:00:52 +0800 Subject: [PATCH 104/108] fix: read member access status through a summary endpoint (SNP-951) Coordinate with SaaS status-only authorization and cover the real BFF transport and cold-cache Normal UI path. Co-authored-by: CodingOnStar --- .../entities/network_access_group_entities.py | 11 ++ api/services/network_access_group_gateway.py | 21 +++ api/services/network_access_group_service.py | 20 ++- .../workspace/test_network_access_group.py | 150 ++++++++++++++++++ .../test_network_access_group_service.py | 32 +++- .../access-control/__tests__/index.spec.tsx | 21 --- .../__tests__/status-only.spec.tsx | 136 ++++++++++++++++ 7 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 web/app/components/app/access-point/access-control/__tests__/status-only.spec.tsx diff --git a/api/services/entities/network_access_group_entities.py b/api/services/entities/network_access_group_entities.py index 589d51ab29a087..521c348f7bab84 100644 --- a/api/services/entities/network_access_group_entities.py +++ b/api/services/entities/network_access_group_entities.py @@ -66,6 +66,17 @@ class NetworkAccessAppConfig: available_access_points: tuple[NetworkAccessPoint, ...] = () +@dataclass(frozen=True) +class NetworkAccessAppStatusSnapshot: + """Control-plane entry state without policy identity or configuration.""" + + entitled: bool + configured: bool + enabled: bool + # Preserve legacy/future scopes so the service can project current App capabilities. + access_points: tuple[str, ...] + + @dataclass(frozen=True) class NetworkAccessAppStatus: """Public entry status without policy identity or configuration.""" diff --git a/api/services/network_access_group_gateway.py b/api/services/network_access_group_gateway.py index 3d634012fabb74..86fc0b811949ac 100644 --- a/api/services/network_access_group_gateway.py +++ b/api/services/network_access_group_gateway.py @@ -24,6 +24,7 @@ from services.billing_service import BillingService, _BillingHTTPStatusError from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessGroup, @@ -176,6 +177,14 @@ class _BindingUpdateWire(_WireModel): effective_enabled: StrictBool = False +class _AppStatusWire(_WireModel): + aliases = {"access_points": ("accessPoints",)} + entitled: StrictBool = False + configured: StrictBool = False + enabled: StrictBool = False + access_points: tuple[StrictStr, ...] = () + + class _DeletedWire(_WireModel): deleted: StrictBool = False @@ -322,6 +331,18 @@ def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) wire.binding.record() if wire.binding is not None else None, ) + @override + def get_app_status(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppStatusSnapshot: + wire = _decode( + _AppStatusWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group/status", + params={"actor_account_id": actor_account_id}, + ), + ) + return NetworkAccessAppStatusSnapshot(wire.entitled, wire.configured, wire.enabled, wire.access_points) + @override def update_app_binding( self, diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py index 940af7a457f88e..13b4e8b4717420 100644 --- a/api/services/network_access_group_service.py +++ b/api/services/network_access_group_service.py @@ -10,6 +10,7 @@ from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, NetworkAccessAppStatus, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -142,6 +143,10 @@ def delete_group( def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppConfig: ... + def get_app_status( + self, workspace_id: str, app_id: str, actor_account_id: str + ) -> NetworkAccessAppStatusSnapshot: ... + def update_app_binding( self, workspace_id: str, @@ -249,14 +254,15 @@ def get_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAcc def get_app_status(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppStatus: """Expose only the Access Point entry state to Normal workspace members.""" self._ensure_workspace_role(context, _APP_STATUS_ROLES) - config = self._read_app_binding(context, app_id=app_id) - binding = config.binding + app = self._get_manageable_app(context.active_workspace_id, app_id) + available_access_points = self._available_access_points(app.mode) + status = self._control_plane.get_app_status(context.active_workspace_id, app.id, context.account_id) return NetworkAccessAppStatus( - entitled=config.entitled, - configured=bool(binding and binding.group_id), - enabled=bool(binding and binding.enabled), - covered_count=len(binding.access_points) if binding else 0, - available_count=len(config.available_access_points), + entitled=self._effective_entitlement(context.active_workspace_id, status.entitled), + configured=status.configured, + enabled=status.enabled, + covered_count=len(set(status.access_points).intersection(available_access_points)), + available_count=len(available_access_points), ) def _read_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppConfig: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py index 0f87566c0ceada..3a73dc30fdec1f 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -272,6 +272,114 @@ def test_app_status_serializes_only_entry_state() -> None: service.get_app_status.assert_called_once_with(_request_context(), app_id=APP_ID) +@pytest.mark.parametrize("role", ["normal", "owner", "admin", "editor"]) +@pytest.mark.parametrize( + ("app_mode", "covered_count", "available_count"), + [ + ("workflow", 3, 4), + ("advanced-chat", 2, 3), + ("chat", 2, 3), + ("completion", 2, 3), + ("agent-chat", 2, 3), + ("agent", 1, 2), + ], +) +def test_app_status_uses_minimal_upstream_route_and_filters_scopes_for_current_mode( + role: str, app_mode: str, covered_count: int, available_count: int +) -> None: + response, requests = _typed_status_http_response( + { + "entitled": True, + "configured": True, + "enabled": True, + "accessPoints": ["webapp", "mcp", "trigger", "future_scope", "webapp"], + "group_id": GROUP_ID, + "allowed_cidrs": ["203.0.113.7/32"], + }, + role=role, + app_mode=app_mode, + ) + + assert response.status_code == 200 + assert response.get_json() == { + "entitled": True, + "configured": True, + "enabled": True, + "covered_count": covered_count, + "available_count": available_count, + } + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status" + assert dict(requests[0].url.params) == {"actor_account_id": ACCOUNT_ID} + assert requests[0].headers["Billing-Api-Secret-Key"] == "synthetic" + + +@pytest.mark.parametrize( + ("wire", "paid", "expected"), + [ + ({}, True, {"entitled": False, "configured": False, "enabled": False, "covered_count": 0}), + ({"entitled": True}, True, {"entitled": True, "configured": False, "enabled": False, "covered_count": 0}), + ( + {"entitled": True, "configured": True, "access_points": ["webapp"]}, + True, + {"entitled": True, "configured": True, "enabled": False, "covered_count": 1}, + ), + ( + {"entitled": True, "configured": True, "enabled": True, "access_points": ["webapp"]}, + False, + {"entitled": False, "configured": True, "enabled": True, "covered_count": 1}, + ), + ], +) +def test_app_status_preserves_saved_state_and_applies_local_entitlement( + wire: Mapping[str, object], paid: bool, expected: dict[str, object] +) -> None: + response, _requests = _typed_status_http_response(wire, paid=paid) + + assert response.status_code == 200 + assert response.get_json() == {**expected, "available_count": 4} + + +@pytest.mark.parametrize("role", ["dataset_operator", "unknown", None]) +def test_app_status_rejects_disallowed_roles_before_upstream_read(role: str | None) -> None: + response, requests = _typed_status_http_response({}, role=role) + + assert response.status_code == 403 + assert requests == [] + + +@pytest.mark.parametrize("upstream_status", [403, 404]) +def test_app_status_does_not_fall_back_to_full_binding_after_upstream_error(upstream_status: int) -> None: + response, requests = _typed_status_http_response({}, upstream_status=upstream_status) + + assert response.status_code == upstream_status + assert len(requests) == 1 + assert requests[0].url.path.endswith("/network-access-group/status") + + +@pytest.mark.parametrize("field", ["entitled", "configured", "enabled"]) +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) +def test_app_status_rejects_non_boolean_flags(field: str, value: object) -> None: + response, _requests = _typed_status_http_response({field: value}) + + assert response.status_code == 502 + + +@pytest.mark.parametrize( + "wire", + [ + {"access_points": "webapp"}, + {"access_points": [1]}, + {"access_points": ["webapp"], "accessPoints": ["mcp"]}, + ], +) +def test_app_status_rejects_malformed_or_conflicting_scopes(wire: Mapping[str, object]) -> None: + response, _requests = _typed_status_http_response(wire) + + assert response.status_code == 502 + + @pytest.mark.parametrize( ("status_code", "expected_exception"), [ @@ -818,3 +926,45 @@ def binding() -> dict[str, object]: with _application_services(service): return app.test_client().get("/binding") + + +def _typed_status_http_response( + wire: Mapping[str, object], + *, + role: str | None = "normal", + app_mode: str = "workflow", + paid: bool = True, + upstream_status: int = 200, +) -> tuple[TestResponse, list[httpx.Request]]: + app = Flask(__name__) + app.testing = True + apps, memberships, entitlement = MagicMock(), MagicMock(), MagicMock() + apps.get_manageable_app.return_value = NetworkAccessGroupAppRecord(APP_ID, app_mode, "Test", None, None, None) + memberships.get_account_role.return_value = role + entitlement.is_paid_plan.return_value = paid + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path != f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status": + return httpx.Response(403, json={"reason": "FORBIDDEN"}) + return httpx.Response(upstream_status, json=wire) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + gateway = NetworkAccessGroupGateway( + base_url="https://test.invalid", + fallback_base_url="", + secret_key="synthetic", + http_client=client, + ) + service = NetworkAccessGroupService( + control_plane=gateway, apps=apps, memberships=memberships, entitlement=entitlement + ) + api = AppNetworkAccessGroupStatusApi() + + @app.get("/status") + def status() -> dict[str, object]: + return unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + + with _application_services(service): + return app.test_client().get("/status"), requests diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py index 5cbdea8fd8dadf..faeef4644c6193 100644 --- a/api/tests/unit_tests/services/test_network_access_group_service.py +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -8,6 +8,7 @@ from machinery.context import RequestContext from services.entities.network_access_group_entities import ( NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, NetworkAccessBinding, NetworkAccessBindingUpdate, NetworkAccessCurrentIPCheck, @@ -128,6 +129,7 @@ def _harness(*, role: str | None = "owner", paid: bool = True, app_mode: str = " for method in (control_plane.get_group, control_plane.create_group, control_plane.update_group): method.return_value = _group() control_plane.get_app_binding.return_value = _config() + control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot(True, False, False, ()) control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate(_binding(), False) control_plane.delete_group.return_value = True control_plane.cleanup_app_binding.return_value = True @@ -342,8 +344,8 @@ def test_app_binding_rejects_disallowed_roles_before_app_lookup(operation: Mutat def test_normal_member_can_read_only_app_entry_status() -> None: harness = _harness(role="normal") - harness.control_plane.get_app_binding.return_value = _config( - _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "service_api")) + harness.control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot( + entitled=True, configured=True, enabled=True, access_points=("webapp", "service_api") ) status = harness.service.get_app_status(_context(), app_id=APP_ID) @@ -355,7 +357,8 @@ def test_normal_member_can_read_only_app_entry_status() -> None: assert status.available_count == 4 assert not hasattr(status, "group_id") assert not hasattr(status, "allowed_cidrs") - harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.get_app_status.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.get_app_binding.assert_not_called() @pytest.mark.parametrize("role", ["dataset_operator", None]) @@ -366,6 +369,29 @@ def test_app_entry_status_rejects_roles_without_app_access(role: str | None) -> harness.service.get_app_status(_context(), app_id=APP_ID) harness.apps.get_manageable_app.assert_not_called() + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_missing_tenant_scoped_app_before_upstream_read() -> None: + harness = _harness(role="normal") + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_unsupported_app_mode_before_upstream_read() -> None: + harness = _harness(role="normal", app_mode="unknown") + + with pytest.raises(NetworkAccessGroupUnsupportedAppModeError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.control_plane.get_app_status.assert_not_called() harness.control_plane.get_app_binding.assert_not_called() diff --git a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx index 067659f9d67908..c96580daa378e1 100644 --- a/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/access-control/__tests__/index.spec.tsx @@ -21,7 +21,6 @@ import { consoleQuery } from '@/service/console' import { createNetworkAccessGroupFixture, seedAppNetworkAccessGroup, - seedAppNetworkAccessGroupStatus, seedNetworkAccessGroups, } from '@/test/console/network-access' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' @@ -70,16 +69,6 @@ const renderEntry = ({ binding, available_access_points: availableAccessPoints, }) - if (role === 'normal') { - seedAppNetworkAccessGroupStatus(queryClient, 'app-1', { - entitled, - configured: Boolean(binding?.group_id), - enabled: binding?.enabled ?? false, - covered_count: binding?.access_points.length ?? 0, - available_count: availableAccessPoints.length, - }) - } - return renderWithConsoleQuery( { expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() }) - it('shows Normal members the status but keeps the entry disabled', () => { - const binding = createBinding({ enabled: true, access_points: ['webapp', 'service_api'] }) - renderEntry({ plan: 'professional', role: 'normal', binding }) - const entry = screen.getByRole('button', { name: /Access Control/ }) - expect(entry).toBeDisabled() - expect(entry).toHaveTextContent('2 of 3') - expect(screen.queryByText('Internal Network')).not.toBeInTheDocument() - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - it('hides the entry from Dataset Operators', () => { renderEntry({ plan: 'professional', role: 'dataset_operator' }) expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() diff --git a/web/app/components/app/access-point/access-control/__tests__/status-only.spec.tsx b/web/app/components/app/access-point/access-control/__tests__/status-only.spec.tsx new file mode 100644 index 00000000000000..251614dbc3e199 --- /dev/null +++ b/web/app/components/app/access-point/access-control/__tests__/status-only.spec.tsx @@ -0,0 +1,136 @@ +import type { AppNetworkAccessGroupStatusResponse } from '@dify/contracts/api/console/apps/types.gen' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { initReactI18next } from 'react-i18next' +import { toast } from '@/app/notifications' +import deploymentTranslations from '@/i18n/locales/en-US/deployments.json' +import { consoleQuery } from '@/service/console' +import { clearRequestErrorToasts } from '@/service/request-error-toast' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { AccessControlEntry } from '..' + +vi.unmock('react-i18next') +vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() } })) + +const statusPath = '/apps/app-1/network-access-group/status' +const requests: Request[] = [] + +const createStatus = ( + overrides: Partial = {}, +): AppNetworkAccessGroupStatusResponse => ({ + entitled: true, + configured: true, + enabled: true, + covered_count: 3, + available_count: 3, + ...overrides, +}) + +const renderNormalEntry = (response: () => Response) => { + const queryClient = createConsoleQueryClient() + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + requests.push(request) + if (new URL(request.url).pathname.endsWith(statusPath) && request.method === 'GET') + return response() + return Response.json({ message: 'Unexpected privileged request' }, { status: 403 }) + }) + + // Only workspace/edition metadata is seeded. The status must cross the real + // Console transport, without configuration or policy-list data in the cache. + return renderWithConsoleQuery( + , + { + queryClient, + currentWorkspace: { role: 'normal' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) +} + +const expectOnlyStatusRequest = () => { + expect(requests).toHaveLength(1) + expect(requests[0]!.method).toBe('GET') + expect(new URL(requests[0]!.url).pathname).toBe(`/console/api${statusPath}`) +} + +beforeEach(async () => { + requests.length = 0 + vi.mocked(toast.error).mockClear() + await createInstance() + .use(initReactI18next) + .init({ + lng: 'en-US', + fallbackLng: 'en-US', + keySeparator: false, + interpolation: { escapeValue: false }, + resources: { 'en-US': { deployments: deploymentTranslations } }, + }) +}) + +afterEach(() => { + for (const request of requests) clearRequestErrorToasts(request) +}) + +describe('Normal member Access Control status', () => { + it.each([ + { + name: 'Off', + status: createStatus({ configured: false, enabled: false, covered_count: 0 }), + label: 'Off', + }, + { name: 'Paused', status: createStatus({ enabled: false }), label: 'Paused' }, + { name: 'On', status: createStatus(), label: 'On' }, + { name: 'partial coverage', status: createStatus({ covered_count: 2 }), label: '2 of 3' }, + { name: 'downgraded', status: createStatus({ entitled: false }), label: 'PRO' }, + ])( + 'loads $name from the status-only API without exposing configuration', + async ({ status, label }) => { + const user = userEvent.setup() + renderNormalEntry(() => Response.json(status)) + + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + const entry = await screen.findByRole('button', { name: /Access Control/ }) + expect(entry).toBeDisabled() + expect(entry).toHaveTextContent(label) + + await user.click(entry) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect( + screen.queryByRole('switch', { name: 'Restrict by IP address' }), + ).not.toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + expectOnlyStatusRequest() + }, + ) + + it.each([403, 503])( + 'does not show a false Off state or fetch configuration after HTTP %s', + async (status) => { + const message = status === 403 ? 'Access denied' : 'Service unavailable' + const rendered = renderNormalEntry(() => Response.json({ message }, { status })) + + await waitFor(() => { + expect( + rendered.queryClient.getQueryState( + consoleQuery.apps.byAppId.networkAccessGroup.status.get.queryKey({ + input: { params: { app_id: 'app-1' } }, + }), + )?.status, + ).toBe('error') + }) + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(toast.error).toHaveBeenCalledExactlyOnceWith(message) + expectOnlyStatusRequest() + }, + ) +}) From 36ae13e4c8b4aec1adf5e9caa4f2eaa7e6e9200d Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 19 Oct 2026 16:31:24 +0800 Subject: [PATCH 105/108] fix(web): align IP access control plan messaging (SNP-985, SNP-986) --- .../header/account-setting/ip-policies-page/index.tsx | 7 +++++++ web/i18n/locales/ar-TN/common.json | 1 + web/i18n/locales/az-AZ/common.json | 1 + web/i18n/locales/de-DE/common.json | 1 + web/i18n/locales/en-US/common.json | 1 + web/i18n/locales/es-ES/common.json | 1 + web/i18n/locales/fa-IR/common.json | 1 + web/i18n/locales/fr-FR/common.json | 1 + web/i18n/locales/hi-IN/common.json | 1 + web/i18n/locales/id-ID/common.json | 1 + web/i18n/locales/it-IT/common.json | 1 + web/i18n/locales/ja-JP/common.json | 1 + web/i18n/locales/ja-JP/deployments.json | 10 +++++----- web/i18n/locales/ko-KR/common.json | 1 + web/i18n/locales/lo-LA/common.json | 1 + web/i18n/locales/nl-NL/common.json | 1 + web/i18n/locales/pl-PL/common.json | 1 + web/i18n/locales/pt-BR/common.json | 1 + web/i18n/locales/ro-RO/common.json | 1 + web/i18n/locales/ru-RU/common.json | 1 + web/i18n/locales/sl-SI/common.json | 1 + web/i18n/locales/th-TH/common.json | 1 + web/i18n/locales/tr-TR/common.json | 1 + web/i18n/locales/uk-UA/common.json | 1 + web/i18n/locales/vi-VN/common.json | 1 + web/i18n/locales/zh-Hans/common.json | 1 + web/i18n/locales/zh-Hans/deployments.json | 6 +++--- web/i18n/locales/zh-Hant/common.json | 1 + web/i18n/resources.generated.d.ts | 1 + 29 files changed, 41 insertions(+), 8 deletions(-) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx index b558b92de75e7e..3f366464c42923 100644 --- a/web/app/components/header/account-setting/ip-policies-page/index.tsx +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -16,6 +16,7 @@ import { pricingQueryParamName, pricingQueryParser, } from '@/app/components/billing/pricing/query-params' +import { currentWorkspaceAtom } from '@/context/workspace-state' import { getIpPolicyFailReason, trackNetworkAccessEvent } from '@/features/network-access/analytics' import { canManageNetworkAccessPoliciesAtom, @@ -65,6 +66,7 @@ function IpPoliciesListSkeleton() { export default function IpPoliciesPage() { const { t } = useTranslation(['common', 'deployments']) const queryClient = useQueryClient() + const { plan } = useAtomValue(currentWorkspaceAtom) const canReadPolicies = useAtomValue(canReadNetworkAccessAtom) const canManagePolicies = useAtomValue(canManageNetworkAccessPoliciesAtom) const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) @@ -162,6 +164,11 @@ export default function IpPoliciesPage() {

    {t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' })}

    + {plan === 'sandbox' && ( +

    + {t(($) => $['settings.ipPoliciesProPlanRequired'], { ns: 'common' })} +

    + )}
    {canManagePolicies && !isEmpty && (