diff --git a/api/controllers/console/workspace/error.py b/api/controllers/console/workspace/error.py index 567009a15cb..a19528eb9a8 100644 --- a/api/controllers/console/workspace/error.py +++ b/api/controllers/console/workspace/error.py @@ -1,6 +1,12 @@ from libs.exception import BaseHTTPException +class CurrentWorkspaceArchivedError(BaseHTTPException): + error_code = "current_workspace_archived" + description = "The current workspace has been archived." + code = 409 + + class RepeatPasswordNotMatchError(BaseHTTPException): error_code = "repeat_password_not_match" description = "New password and repeat password does not match." diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 2f83df3c0bb..31781616af3 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -7,9 +7,10 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound, Unauthorized +from werkzeug.exceptions import NotFound import services +from configs import dify_config from controllers.common.errors import ( FilenameNotExistsError, FileTooLargeError, @@ -28,6 +29,7 @@ from controllers.console import console_ns from controllers.console.admin import admin_required from controllers.console.error import AccountNotLinkTenantError from controllers.console.flask_admission import console_account_admission +from controllers.console.workspace.error import CurrentWorkspaceArchivedError from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, @@ -43,7 +45,7 @@ from libs.helper import dump_response, to_timestamp from libs.login import login_required from libs.pagination import paginate_query from machinery.context import RequestContext -from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus +from models.account import Account, Tenant, TenantAccountRole, TenantCustomConfigDict, TenantStatus from services.account_service import TenantService from services.enterprise.enterprise_service import EnterpriseService from services.file_service import FileService @@ -105,6 +107,14 @@ class TenantInfoResponse(ResponseModel): return to_timestamp(value) +class CurrentWorkspaceSummaryResponse(ResponseModel): + id: str + name: str + role: TenantAccountRole + plan: str | None + credits: int | None = Field(description="Remaining credits in the effective pool; -1 means unlimited.") + + class TenantListItemResponse(ResponseModel): id: str name: str | None = None @@ -201,6 +211,7 @@ register_schema_models( ) register_response_schema_models( console_ns, + CurrentWorkspaceSummaryResponse, TenantInfoResponse, TenantListItemResponse, TenantListResponse, @@ -245,35 +256,31 @@ class WorkspaceListApi(Resource): ).model_dump(mode="json"), HTTPStatus.OK -@console_ns.route("/workspaces/current", endpoint="workspaces_current") -@console_ns.route("/info", endpoint="info") # Deprecated -class TenantApi(Resource): +@console_ns.route("/workspaces/current/summary") +class CurrentWorkspaceSummaryApi(Resource): + @console_ns.response( + HTTPStatus.OK, + "Success", + console_ns.models[CurrentWorkspaceSummaryResponse.__name__], + ) + @console_ns.response(HTTPStatus.CONFLICT, "Current workspace is archived") @setup_required @login_required @account_initialization_required - @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantInfoResponse.__name__]) @with_current_user - @with_session - def post(self, session: Session, current_user: Account): - if request.path == "/info": - logger.warning("Deprecated URL /info was used.") - + @with_session(write=False) + def get(self, session: Session, current_user: Account): tenant = current_user.current_tenant if not tenant: raise ValueError("No current tenant") - if tenant.status == TenantStatus.ARCHIVE: - tenants = TenantService.get_join_tenants(current_user, session=session) - # if there is any tenant, switch to the first one - if len(tenants) > 0: - TenantService.switch_tenant(current_user, tenants[0].id, session=session) - tenant = tenants[0] - # else, raise Unauthorized - else: - raise Unauthorized("workspace is archived") + raise CurrentWorkspaceArchivedError() return ( - dump_response(TenantInfoResponse, WorkspaceService.get_tenant_info(tenant, session=session)), + dump_response( + CurrentWorkspaceSummaryResponse, + WorkspaceService.get_current_workspace_summary(tenant, current_user.id, session=session), + ), HTTPStatus.OK, ) @@ -308,6 +315,31 @@ class SwitchWorkspaceApi(Resource): @console_ns.route("/workspaces/custom-config") class CustomConfigWorkspaceApi(Resource): + @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceCustomConfigResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_tenant_id + @with_session(write=False) + def get(self, session: Session, current_tenant_id: str): + tenant = TenantService.get_tenant_by_id(current_tenant_id, session=session) + if tenant is None: + raise NotFound() + + custom_config = tenant.custom_config_dict + replace_webapp_logo = ( + f"{dify_config.FILES_URL}/files/workspaces/{tenant.id}/webapp-logo" + if custom_config.get("replace_webapp_logo") + else None + ) + return dump_response( + WorkspaceCustomConfigResponse, + { + "remove_webapp_brand": custom_config.get("remove_webapp_brand", False), + "replace_webapp_logo": replace_webapp_logo, + }, + ) + @console_ns.expect(console_ns.models[WorkspaceCustomConfigPayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[WorkspaceTenantResultResponse.__name__]) @setup_required diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index d6e3b3edefb..651132c2df3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -7037,13 +7037,6 @@ Request body: | ---- | ----------- | ------ | | 200 | Success | **application/json**: [ConsoleHumanInputFormSubmitResponse](#consolehumaninputformsubmitresponse) | -### [POST] /info -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse) | - ### [GET] /installed-apps #### Parameters @@ -10069,13 +10062,6 @@ Returns information about why and where the workflow is paused. | ---- | ----------- | ------ | | 200 | Success | **application/json**: [TenantListResponse](#tenantlistresponse) | -### [POST] /workspaces/current -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Success | **application/json**: [TenantInfoResponse](#tenantinforesponse) | - ### [GET] /workspaces/current/agent-provider/{provider_name} Get specific agent provider details @@ -12031,6 +12017,14 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix) | +### [GET] /workspaces/current/summary +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [CurrentWorkspaceSummaryResponse](#currentworkspacesummaryresponse) | +| 409 | Current workspace is archived | | + ### [GET] /workspaces/current/tool-labels #### Responses @@ -12838,6 +12832,13 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Trigger providers retrieved successfully | **application/json**: [TriggerProviderListResponse](#triggerproviderlistresponse) | +### [GET] /workspaces/custom-config +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceCustomConfigResponse](#workspacecustomconfigresponse) | + ### [POST] /workspaces/custom-config #### Request Body @@ -16619,6 +16620,16 @@ Model class for credential form schema. | ---- | ---- | ----------- | -------- | | CredentialType | string | | | +#### CurrentWorkspaceSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credits | integer | Remaining credits in the effective pool; -1 means unlimited. | Yes | +| id | string | | Yes | +| name | string | | Yes | +| plan | string | | Yes | +| role | [TenantAccountRole](#tenantaccountrole) | | Yes | + #### CustomConfigurationResponse Model class for provider custom configuration response. diff --git a/api/services/account_service.py b/api/services/account_service.py index 5c68f9b6fb2..5d41af5dc10 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -325,26 +325,41 @@ class AccountService: if account.status == AccountStatus.BANNED: raise Unauthorized("Account is banned.") - current_tenant = session.scalar( + current_tenant_join = session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.account_id == account.id, TenantAccountJoin.current == True) .limit(1) ) - if current_tenant: - account.set_tenant_id_with_session(current_tenant.tenant_id, session=session) - else: - available_ta = session.scalar( + if current_tenant_join is not None: + account.set_tenant_id_with_session(current_tenant_join.tenant_id, session=session) + + has_valid_current_tenant = ( + current_tenant_join is not None + and account.current_tenant is not None + and account.current_tenant.status == TenantStatus.NORMAL + ) + if not has_valid_current_tenant: + if current_tenant_join is not None: + current_tenant_join.current = False + + available_tenant_join = session.scalar( select(TenantAccountJoin) - .where(TenantAccountJoin.account_id == account.id) + .join(Tenant, TenantAccountJoin.tenant_id == Tenant.id) + .where( + TenantAccountJoin.account_id == account.id, + Tenant.status == TenantStatus.NORMAL, + ) .order_by(TenantAccountJoin.id.asc()) .limit(1) ) - if not available_ta: + if available_tenant_join is None: + if current_tenant_join is not None: + session.commit() return None - account.set_tenant_id_with_session(available_ta.tenant_id, session=session) - available_ta.current = True - available_ta.last_opened_at = naive_utc_now() + account.set_tenant_id_with_session(available_tenant_join.tenant_id, session=session) + available_tenant_join.current = True + available_tenant_join.last_opened_at = naive_utc_now() session.commit() AccountService._refresh_account_last_active(account, session) diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index aa09b24278c..dc4743eac84 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -96,6 +96,25 @@ class WorkspaceService: next_credit_reset_date=billing_info.get("next_credit_reset_date"), ) + @classmethod + def get_current_workspace_summary(cls, tenant: Tenant, account_id: str, *, session: Session) -> dict[str, object]: + tenant_account_join = session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account_id) + .limit(1) + ) + assert tenant_account_join is not None, "TenantAccountJoin not found" + + effective_pool = cls.get_effective_credit_pool(tenant.id, session=session) + + return { + "id": tenant.id, + "name": tenant.name, + "role": tenant_account_join.role, + "plan": effective_pool.plan, + "credits": effective_pool.remaining_credits, + } + @classmethod def get_tenant_info(cls, tenant: Tenant, session: Session): if not tenant: diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index 525327a9e9d..cd1afae7f5e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -5,14 +5,14 @@ from http import HTTPStatus from inspect import unwrap from io import BytesIO from types import SimpleNamespace -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy import Engine, event from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import Unauthorized +from werkzeug.exceptions import NotFound import services from controllers.common.errors import ( @@ -22,11 +22,13 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.console import console_ns from controllers.console.error import AccountNotLinkTenantError +from controllers.console.workspace.error import CurrentWorkspaceArchivedError from controllers.console.workspace.workspace import ( + CurrentWorkspaceSummaryApi, CustomConfigWorkspaceApi, SwitchWorkspaceApi, - TenantApi, TenantInfoResponse, TenantListApi, WebappLogoWorkspaceApi, @@ -334,66 +336,59 @@ class TestWorkspaceListApi: assert result["has_more"] is True -class TestTenantApi: - def test_post_active_tenant(self, app: Flask): - api = TenantApi() - method = unwrap(api.post) +def test_legacy_current_workspace_routes_are_not_registered(): + urls = {url for _resource, resource_urls, _route_doc, _kwargs in console_ns.resources for url in resource_urls} + + assert "/workspaces/current" not in urls + assert "/info" not in urls + + +class TestCurrentWorkspaceSummaryApi: + def test_get_summary(self, app: Flask): + api = CurrentWorkspaceSummaryApi() + method = unwrap(api.get) tenant = make_tenant() user = make_account_with_tenant(tenant) + session = MagicMock() + summary = { + "id": tenant.id, + "name": tenant.name, + "role": "owner", + "plan": CloudPlan.SANDBOX, + "credits": 180, + } + with ( - app.test_request_context("/workspaces/current"), + app.test_request_context("/workspaces/current/summary"), patch( - "controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"} - ), + "controllers.console.workspace.workspace.WorkspaceService.get_current_workspace_summary", + return_value=summary, + ) as get_summary, ): - result, status = method(api, MagicMock(), user) + result, status = method(api, session, user) + assert status == HTTPStatus.OK - assert result["id"] == "t1" + assert result == { + "id": tenant.id, + "name": tenant.name, + "role": "owner", + "plan": "sandbox", + "credits": 180, + } + get_summary.assert_called_once_with(tenant, user.id, session=session) - def test_post_archived_with_switch(self, app: Flask): - api = TenantApi() - method = unwrap(api.post) - archived = make_tenant(status=TenantStatus.ARCHIVE) - new_tenant = make_tenant("new") - user = make_account_with_tenant(archived) - with ( - app.test_request_context("/workspaces/current"), - patch("controllers.console.workspace.workspace.TenantService.get_join_tenants", return_value=[new_tenant]), - patch("controllers.console.workspace.workspace.TenantService.switch_tenant") as switch_tenant, - patch( - "controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "new"} - ), - ): - result, status = method(api, MagicMock(), user) - assert result["id"] == "new" - switch_tenant.assert_called_once_with(user, new_tenant.id, session=ANY) + def test_get_archived_tenant_returns_conflict(self, app: Flask): + api = CurrentWorkspaceSummaryApi() + method = unwrap(api.get) + tenant = make_tenant(status=TenantStatus.ARCHIVE) + user = make_account_with_tenant(tenant) - def test_post_archived_no_tenant(self, app: Flask): - api = TenantApi() - method = unwrap(api.post) - user = make_account_with_tenant(make_tenant(status=TenantStatus.ARCHIVE)) - with ( - app.test_request_context("/workspaces/current"), - patch("controllers.console.workspace.workspace.TenantService.get_join_tenants", return_value=[]), - ): - with pytest.raises(Unauthorized): + with app.test_request_context("/workspaces/current/summary"): + with pytest.raises(CurrentWorkspaceArchivedError) as exc_info: method(api, MagicMock(), user) - def test_post_info_path(self, app: Flask, caplog: pytest.LogCaptureFixture): - api = TenantApi() - method = unwrap(api.post) - tenant = make_tenant() - user = make_account_with_tenant(tenant) - with ( - app.test_request_context("/info"), - caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"), - patch( - "controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"} - ), - ): - result, status = method(api, MagicMock(), user) - assert "Deprecated URL /info was used." in caplog.messages - assert status == HTTPStatus.OK + assert exc_info.value.code == HTTPStatus.CONFLICT + assert exc_info.value.error_code == "current_workspace_archived" class TestTenantInfoResponse: @@ -467,6 +462,43 @@ class TestSwitchWorkspaceApi: class TestCustomConfigWorkspaceApi: + def test_get_workspace_not_found(self, app: Flask, workspace_session: scoped_session[Session]): + api = CustomConfigWorkspaceApi() + method = unwrap(api.get) + + with app.test_request_context("/workspaces/custom-config"), pytest.raises(NotFound): + method(api, workspace_session, "missing") + + def test_get_defaults(self, app: Flask, workspace_session: scoped_session[Session]): + api = CustomConfigWorkspaceApi() + method = unwrap(api.get) + tenant = make_tenant(custom_config={}) + workspace_session.add(tenant) + workspace_session.commit() + + with app.test_request_context("/workspaces/custom-config"): + result = method(api, workspace_session, tenant.id) + + assert result == {"remove_webapp_brand": False, "replace_webapp_logo": None} + + def test_get_configured_brand(self, app: Flask, workspace_session: scoped_session[Session]): + api = CustomConfigWorkspaceApi() + method = unwrap(api.get) + tenant = make_tenant(custom_config={"remove_webapp_brand": True, "replace_webapp_logo": "logo-file-id"}) + workspace_session.add(tenant) + workspace_session.commit() + + with ( + app.test_request_context("/workspaces/custom-config"), + patch("controllers.console.workspace.workspace.dify_config.FILES_URL", "https://files.example.com"), + ): + result = method(api, workspace_session, tenant.id) + + assert result == { + "remove_webapp_brand": True, + "replace_webapp_logo": f"https://files.example.com/files/workspaces/{tenant.id}/webapp-logo", + } + def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]): api = CustomConfigWorkspaceApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index d82ee5dbfc8..3bd227f1e3e 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -456,14 +456,11 @@ class TestAccountService: sqlite_session.add(tenant_join) sqlite_session.commit() - with ( - patch.object(Account, "set_tenant_id_with_session") as mock_set_tenant_id, - patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active, - ): + with patch.object(AccountService, "_refresh_account_last_active") as mock_refresh_last_active: result = AccountService.load_user(account.id, sqlite_session) assert result is account - mock_set_tenant_id.assert_called_once_with(tenant.id, session=sqlite_session) + assert result.current_tenant_id == tenant.id mock_refresh_last_active.assert_called_once_with(account, sqlite_session) def test_load_user_not_found(self, sqlite_session: Session) -> None: @@ -519,6 +516,54 @@ class TestAccountService: assert persisted_tenant_join.current is True assert persisted_tenant_join.last_opened_at == mock_now + def test_load_user_switches_from_archived_current_tenant(self, sqlite_session: Session) -> None: + account = Account(name="Test User", email="test@example.com") + archived_tenant = Tenant(name="Archived Workspace", status=TenantStatus.ARCHIVE) + available_tenant = Tenant(name="Available Workspace") + sqlite_session.add_all([account, archived_tenant, available_tenant]) + sqlite_session.flush() + archived_join = TenantAccountJoin( + tenant_id=archived_tenant.id, + account_id=account.id, + role=TenantAccountRole.NORMAL, + current=True, + ) + available_join = TenantAccountJoin( + tenant_id=available_tenant.id, + account_id=account.id, + role=TenantAccountRole.NORMAL, + current=False, + ) + sqlite_session.add_all([archived_join, available_join]) + sqlite_session.commit() + + with patch.object(AccountService, "_refresh_account_last_active"): + result = AccountService.load_user(account.id, sqlite_session) + + assert result is account + assert result.current_tenant_id == available_tenant.id + assert archived_join.current is False + assert available_join.current is True + + def test_load_user_returns_none_without_normal_tenant(self, sqlite_session: Session) -> None: + account = Account(name="Test User", email="test@example.com") + archived_tenant = Tenant(name="Archived Workspace", status=TenantStatus.ARCHIVE) + sqlite_session.add_all([account, archived_tenant]) + sqlite_session.flush() + archived_join = TenantAccountJoin( + tenant_id=archived_tenant.id, + account_id=account.id, + role=TenantAccountRole.NORMAL, + current=True, + ) + sqlite_session.add(archived_join) + sqlite_session.commit() + + result = AccountService.load_user(account.id, sqlite_session) + + assert result is None + assert archived_join.current is False + def test_load_user_keeps_tenant_accessible_with_expiring_session(self, sqlite_session: Session) -> None: account = Account(name="Test User", email="test@example.com") tenant = Tenant(name="Test Workspace") diff --git a/api/tests/unit_tests/services/test_workspace_service.py b/api/tests/unit_tests/services/test_workspace_service.py new file mode 100644 index 00000000000..435c7a5dfa2 --- /dev/null +++ b/api/tests/unit_tests/services/test_workspace_service.py @@ -0,0 +1,108 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +from enums.cloud_plan import CloudPlan +from models.account import Tenant +from services.credit_pool_service import CreditPoolBalance +from services.workspace_service import WorkspaceService + + +def test_get_current_workspace_summary_sandbox_uses_trial_only() -> None: + tenant = Tenant(name="Workspace") + membership = SimpleNamespace(role="owner") + session = MagicMock() + session.scalar.return_value = membership + trial_pool = CreditPoolBalance( + tenant_id=tenant.id, + pool_type="trial", + quota_limit=200, + quota_used=20, + ) + billing_info = { + "enabled": True, + "subscription": {"plan": CloudPlan.SANDBOX}, + } + config = SimpleNamespace(BILLING_ENABLED=True) + + with ( + patch("services.workspace_service.dify_config", config), + patch("services.workspace_service.BillingService.get_info", return_value=billing_info) as get_info, + patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=trial_pool) as get_pool, + patch("services.workspace_service.FeatureService.get_features") as get_features, + ): + result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session) + + assert result == { + "id": tenant.id, + "name": tenant.name, + "role": "owner", + "plan": CloudPlan.SANDBOX, + "credits": 180, + } + get_info.assert_called_once_with(tenant.id, exclude_vector_space=True) + get_pool.assert_called_once_with(tenant_id=tenant.id, pool_type="trial", session=session) + get_features.assert_not_called() + + +def test_get_current_workspace_summary_falls_back_from_exhausted_paid_pool() -> None: + tenant = Tenant(name="Workspace") + session = MagicMock() + session.scalar.return_value = SimpleNamespace(role="admin") + paid_pool = CreditPoolBalance( + tenant_id=tenant.id, + pool_type="paid", + quota_limit=500, + quota_used=500, + ) + trial_pool = CreditPoolBalance( + tenant_id=tenant.id, + pool_type="trial", + quota_limit=100, + quota_used=40, + ) + billing_info = { + "enabled": True, + "subscription": {"plan": CloudPlan.TEAM}, + } + config = SimpleNamespace(BILLING_ENABLED=True) + + with ( + patch("services.workspace_service.dify_config", config), + patch("services.workspace_service.BillingService.get_info", return_value=billing_info), + patch( + "services.credit_pool_service.CreditPoolService.get_pool", + side_effect=[paid_pool, trial_pool], + ) as get_pool, + ): + result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session) + + assert result["plan"] == CloudPlan.TEAM + assert result["credits"] == 60 + assert get_pool.call_args_list == [ + call(tenant_id=tenant.id, pool_type="paid", session=session), + call(tenant_id=tenant.id, pool_type="trial", session=session), + ] + + +def test_get_current_workspace_summary_billing_disabled_skips_billing_and_credits() -> None: + tenant = Tenant(name="Workspace") + session = MagicMock() + session.scalar.return_value = SimpleNamespace(role="editor") + config = SimpleNamespace(BILLING_ENABLED=False) + + with ( + patch("services.workspace_service.dify_config", config), + patch("services.workspace_service.BillingService.get_info") as get_info, + patch("services.credit_pool_service.CreditPoolService.get_pool") as get_pool, + ): + result = WorkspaceService.get_current_workspace_summary(tenant, "account-1", session=session) + + assert result == { + "id": tenant.id, + "name": tenant.name, + "role": "editor", + "plan": None, + "credits": None, + } + get_info.assert_not_called() + get_pool.assert_not_called() diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 7bb01c2b98b..408fd1d9083 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -5919,7 +5919,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 8 + "count": 6 } }, "web/service/datasets.ts": { diff --git a/packages/contracts/generated/api/console/info/orpc.gen.ts b/packages/contracts/generated/api/console/info/orpc.gen.ts deleted file mode 100644 index 2563a61ce4e..00000000000 --- a/packages/contracts/generated/api/console/info/orpc.gen.ts +++ /dev/null @@ -1,22 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { oc } from '@orpc/contract' -import { zPostInfoResponse } from './zod.gen' - -export const post = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postInfo', - path: '/info', - tags: ['console'], - }) - .output(zPostInfoResponse) - -export const info = { - post, -} - -export const contract = { - info, -} diff --git a/packages/contracts/generated/api/console/info/types.gen.ts b/packages/contracts/generated/api/console/info/types.gen.ts deleted file mode 100644 index 1902b2ab6e7..00000000000 --- a/packages/contracts/generated/api/console/info/types.gen.ts +++ /dev/null @@ -1,39 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}/console/api` | (string & {}) -} - -export type TenantInfoResponse = { - created_at?: number | null - custom_config?: WorkspaceCustomConfigResponse | null - id: string - in_trial?: boolean | null - name?: string | null - next_credit_reset_date?: number | null - plan?: string | null - role?: string | null - status?: string | null - trial_credits?: number | null - trial_credits_exhausted_at?: number | null - trial_credits_used?: number | null - trial_end_reason?: string | null -} - -export type WorkspaceCustomConfigResponse = { - remove_webapp_brand?: boolean | null - replace_webapp_logo?: string | null -} - -export type PostInfoData = { - body?: never - path?: never - query?: never - url: '/info' -} - -export type PostInfoResponses = { - 200: TenantInfoResponse -} - -export type PostInfoResponse = PostInfoResponses[keyof PostInfoResponses] diff --git a/packages/contracts/generated/api/console/info/zod.gen.ts b/packages/contracts/generated/api/console/info/zod.gen.ts deleted file mode 100644 index e116e2dbe4a..00000000000 --- a/packages/contracts/generated/api/console/info/zod.gen.ts +++ /dev/null @@ -1,35 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import * as z from 'zod' - -/** - * WorkspaceCustomConfigResponse - */ -export const zWorkspaceCustomConfigResponse = z.object({ - remove_webapp_brand: z.boolean().nullish(), - replace_webapp_logo: z.string().nullish(), -}) - -/** - * TenantInfoResponse - */ -export const zTenantInfoResponse = z.object({ - created_at: z.int().nullish(), - custom_config: zWorkspaceCustomConfigResponse.nullish(), - id: z.string(), - in_trial: z.boolean().nullish(), - name: z.string().nullish(), - next_credit_reset_date: z.int().nullish(), - plan: z.string().nullish(), - role: z.string().nullish(), - status: z.string().nullish(), - trial_credits: z.int().nullish(), - trial_credits_exhausted_at: z.int().nullish(), - trial_credits_used: z.int().nullish(), - trial_end_reason: z.string().nullish(), -}) - -/** - * Success - */ -export const zPostInfoResponse = zTenantInfoResponse diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index eda2bbfcefd..db816dd03c4 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -34,7 +34,6 @@ export const contractLoaders = { forgotPassword: () => import('./forgot-password/orpc.gen').then(({ forgotPassword }) => ({ forgotPassword })), form: () => import('./form/orpc.gen').then(({ form }) => ({ form })), - info: () => import('./info/orpc.gen').then(({ info }) => ({ info })), init: () => import('./init/orpc.gen').then(({ init }) => ({ init })), installedApps: () => import('./installed-apps/orpc.gen').then(({ installedApps }) => ({ installedApps })), diff --git a/packages/contracts/generated/api/console/router.gen.ts b/packages/contracts/generated/api/console/router.gen.ts index a28155f4eea..a036eec4e06 100644 --- a/packages/contracts/generated/api/console/router.gen.ts +++ b/packages/contracts/generated/api/console/router.gen.ts @@ -23,7 +23,6 @@ import { features } from './features/orpc.gen' import { files } from './files/orpc.gen' import { forgotPassword } from './forgot-password/orpc.gen' import { form } from './form/orpc.gen' -import { info } from './info/orpc.gen' import { init } from './init/orpc.gen' import { installedApps } from './installed-apps/orpc.gen' import { instructionGenerate } from './instruction-generate/orpc.gen' @@ -80,7 +79,6 @@ const communityContract = { files, forgotPassword, form, - info, init, installedApps, instructionGenerate, diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 2d80fb7a4f1..292ad4b0ebe 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -151,6 +151,7 @@ import { zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath, zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse, zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse, + zGetWorkspacesCurrentSummaryResponse, zGetWorkspacesCurrentToolLabelsResponse, zGetWorkspacesCurrentToolProviderApiGetQuery, zGetWorkspacesCurrentToolProviderApiGetResponse, @@ -205,6 +206,7 @@ import { zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizePath, zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse, zGetWorkspacesCurrentTriggersResponse, + zGetWorkspacesCustomConfigResponse, zGetWorkspacesResponse, zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody, zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, @@ -319,7 +321,6 @@ import { zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath, zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse, zPostWorkspacesCurrentRbacRolesResponse, - zPostWorkspacesCurrentResponse, zPostWorkspacesCurrentToolProviderApiAddBody, zPostWorkspacesCurrentToolProviderApiAddResponse, zPostWorkspacesCurrentToolProviderApiDeleteBody, @@ -3037,6 +3038,20 @@ export const rbac = { } export const get62 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentSummary', + path: '/workspaces/current/summary', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentSummaryResponse) + +export const summary2 = { + get: get62, +} + +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3047,7 +3062,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get62, + get: get63, } export const post48 = oc @@ -3080,7 +3095,7 @@ export const delete13 = { post: post49, } -export const get63 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3091,11 +3106,11 @@ export const get63 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get64 = { - get: get63, +export const get65 = { + get: get64, } -export const get65 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3107,7 +3122,7 @@ export const get65 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get65, + get: get66, } export const post50 = oc @@ -3144,7 +3159,7 @@ export const test = { pre, } -export const get66 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3156,7 +3171,7 @@ export const get66 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get66, + get: get67, } export const post52 = oc @@ -3177,7 +3192,7 @@ export const update2 = { export const api = { add, delete: delete13, - get: get64, + get: get65, remote, schema, test, @@ -3205,7 +3220,7 @@ export const add2 = { post: post53, } -export const get67 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3222,10 +3237,10 @@ export const get67 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get67, + get: get68, } -export const get68 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3245,7 +3260,7 @@ export const get68 = oc ) export const byCredentialType = { - get: get68, + get: get69, } export const schema2 = { @@ -3257,7 +3272,7 @@ export const credential = { schema: schema2, } -export const get69 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3274,7 +3289,7 @@ export const get69 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get69, + get: get70, } export const post54 = oc @@ -3317,7 +3332,7 @@ export const delete14 = { post: post55, } -export const get70 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3329,10 +3344,10 @@ export const get70 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get70, + get: get71, } -export const get71 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3344,10 +3359,10 @@ export const get71 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get71, + get: get72, } -export const get72 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3361,7 +3376,7 @@ export const get72 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get72, + get: get73, } export const delete15 = oc @@ -3379,7 +3394,7 @@ export const delete15 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get73 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3410,7 +3425,7 @@ export const post56 = oc export const customClient = { delete: delete15, - get: get73, + get: get74, post: post56, } @@ -3419,7 +3434,7 @@ export const oauth = { customClient, } -export const get74 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3431,7 +3446,7 @@ export const get74 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get74, + get: get75, } export const post57 = oc @@ -3486,7 +3501,7 @@ export const auth = { post: post58, } -export const get75 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3498,14 +3513,14 @@ export const get75 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get75, + get: get76, } export const tools3 = { byProviderId, } -export const get76 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3517,7 +3532,7 @@ export const get76 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get76, + get: get77, } export const update4 = { @@ -3596,7 +3611,7 @@ export const delete17 = { post: post61, } -export const get77 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3607,11 +3622,11 @@ export const get77 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get78 = { - get: get77, +export const get79 = { + get: get78, } -export const get79 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3623,7 +3638,7 @@ export const get79 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get79, + get: get80, } export const post62 = oc @@ -3644,7 +3659,7 @@ export const update5 = { export const workflow = { create: create2, delete: delete17, - get: get78, + get: get79, tools: tools4, update: update5, } @@ -3656,7 +3671,7 @@ export const toolProvider = { workflow, } -export const get80 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3668,10 +3683,10 @@ export const get80 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get80, + get: get81, } -export const get81 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3682,10 +3697,10 @@ export const get81 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get81, + get: get82, } -export const get82 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3696,10 +3711,10 @@ export const get82 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get82, + get: get83, } -export const get83 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3710,10 +3725,10 @@ export const get83 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get83, + get: get84, } -export const get84 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3724,7 +3739,7 @@ export const get84 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get84, + get: get85, } export const tools5 = { @@ -3734,7 +3749,7 @@ export const tools5 = { workflow: workflow2, } -export const get85 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3746,13 +3761,13 @@ export const get85 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get85, + get: get86, } /** * Get info for a trigger provider */ -export const get86 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3765,7 +3780,7 @@ export const get86 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get86, + get: get87, } /** @@ -3786,7 +3801,7 @@ export const delete18 = oc /** * Get OAuth client configuration for a provider */ -export const get87 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3820,7 +3835,7 @@ export const post63 = oc export const client = { delete: delete18, - get: get87, + get: get88, post: post63, } @@ -3887,7 +3902,7 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider */ -export const get88 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3908,7 +3923,7 @@ export const get88 = oc ) export const bySubscriptionBuilderId2 = { - get: get88, + get: get89, } export const logs = { @@ -3982,7 +3997,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get89 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4003,7 +4018,7 @@ export const get89 = oc ) export const bySubscriptionBuilderId5 = { - get: get89, + get: get90, } export const builder = { @@ -4018,7 +4033,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get90 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4031,13 +4046,13 @@ export const get90 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get90, + get: get91, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get91 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4054,7 +4069,7 @@ export const get91 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get91, + get: get92, } export const oauth3 = { @@ -4171,7 +4186,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get92 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4183,21 +4198,10 @@ export const get92 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get92, + get: get93, } -export const post71 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrent', - path: '/workspaces/current', - tags: ['console'], - }) - .output(zPostWorkspacesCurrentResponse) - export const current = { - post: post71, agentProvider, agentProviders, customizedSnippets, @@ -4210,6 +4214,7 @@ export const current = { permission, plugin: plugin2, rbac, + summary: summary2, toolLabels, toolProvider, toolProviders, @@ -4218,7 +4223,7 @@ export const current = { triggers, } -export const post72 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4231,14 +4236,24 @@ export const post72 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload2 = { - post: post72, + post: post71, } export const webappLogo = { upload: upload2, } -export const post73 = oc +export const get94 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCustomConfig', + path: '/workspaces/custom-config', + tags: ['console'], + }) + .output(zGetWorkspacesCustomConfigResponse) + +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4250,11 +4265,12 @@ export const post73 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - post: post73, + get: get94, + post: post72, webappLogo, } -export const post74 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4266,10 +4282,10 @@ export const post74 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post74, + post: post73, } -export const post75 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4281,10 +4297,10 @@ export const post75 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post75, + post: post74, } -export const get93 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4296,7 +4312,7 @@ export const get93 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get93, + get: get95, } export const byIconType = { @@ -4315,7 +4331,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get94 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4326,7 +4342,7 @@ export const get94 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get94, + get: get96, 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 70b1a6874f9..ab5fedeca1d 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -8,22 +8,6 @@ export type TenantListResponse = { workspaces: Array } -export type TenantInfoResponse = { - created_at?: number | null - custom_config?: WorkspaceCustomConfigResponse | null - id: string - in_trial?: boolean | null - name?: string | null - next_credit_reset_date?: number | null - plan?: string | null - role?: string | null - status?: string | null - trial_credits?: number | null - trial_credits_exhausted_at?: number | null - trial_credits_used?: number | null - trial_end_reason?: string | null -} - export type AgentProviderResponse = { [key: string]: unknown } @@ -658,6 +642,14 @@ export type WorkspaceAccessMatrix = { pagination?: Pagination | null } +export type CurrentWorkspaceSummaryResponse = { + credits: number | null + id: string + name: string + plan: string | null + role: TenantAccountRole +} + export type ToolLabelListResponse = Array export type ApiToolProviderAddPayload = { @@ -1023,6 +1015,11 @@ export type TriggerOAuthAuthorizeResponse = { export type TriggerProviderListResponse = Array +export type WorkspaceCustomConfigResponse = { + remove_webapp_brand?: boolean | null + replace_webapp_logo?: string | null +} + export type WorkspaceCustomConfigPayload = { remove_webapp_brand?: boolean | null replace_webapp_logo?: string | null @@ -1060,11 +1057,6 @@ export type TenantListItemResponse = { status?: string | null } -export type WorkspaceCustomConfigResponse = { - remove_webapp_brand?: boolean | null - replace_webapp_logo?: string | null -} - export type SnippetListItemResponse = { author_name: string | null created_at: number @@ -1549,6 +1541,8 @@ export type AccessPolicyRole = { role_tag?: string } +export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner' + export type ToolLabel = { icon: string label: I18nObject @@ -1708,6 +1702,22 @@ export type TriggerProviderSubscriptionApiEntity = { workflows_in_use: number } +export type TenantInfoResponse = { + created_at?: number | null + custom_config?: WorkspaceCustomConfigResponse | null + id: string + in_trial?: boolean | null + name?: string | null + next_credit_reset_date?: number | null + plan?: string | null + role?: string | null + status?: string | null + trial_credits?: number | null + trial_credits_exhausted_at?: number | null + trial_credits_used?: number | null + trial_end_reason?: string | null +} + export type PluginDependencyType = 'github' | 'marketplace' | 'package' export type Github = { @@ -2489,20 +2499,6 @@ export type GetWorkspacesResponses = { export type GetWorkspacesResponse = GetWorkspacesResponses[keyof GetWorkspacesResponses] -export type PostWorkspacesCurrentData = { - body?: never - path?: never - query?: never - url: '/workspaces/current' -} - -export type PostWorkspacesCurrentResponses = { - 200: TenantInfoResponse -} - -export type PostWorkspacesCurrentResponse = - PostWorkspacesCurrentResponses[keyof PostWorkspacesCurrentResponses] - export type GetWorkspacesCurrentAgentProviderByProviderNameData = { body?: never path: { @@ -4732,6 +4728,24 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = { export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses] +export type GetWorkspacesCurrentSummaryData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/summary' +} + +export type GetWorkspacesCurrentSummaryErrors = { + 409: unknown +} + +export type GetWorkspacesCurrentSummaryResponses = { + 200: CurrentWorkspaceSummaryResponse +} + +export type GetWorkspacesCurrentSummaryResponse = + GetWorkspacesCurrentSummaryResponses[keyof GetWorkspacesCurrentSummaryResponses] + export type GetWorkspacesCurrentToolLabelsData = { body?: never path?: never @@ -5631,6 +5645,20 @@ export type GetWorkspacesCurrentTriggersResponses = { export type GetWorkspacesCurrentTriggersResponse = GetWorkspacesCurrentTriggersResponses[keyof GetWorkspacesCurrentTriggersResponses] +export type GetWorkspacesCustomConfigData = { + body?: never + path?: never + query?: never + url: '/workspaces/custom-config' +} + +export type GetWorkspacesCustomConfigResponses = { + 200: WorkspaceCustomConfigResponse +} + +export type GetWorkspacesCustomConfigResponse = + GetWorkspacesCustomConfigResponses[keyof GetWorkspacesCustomConfigResponses] + export type PostWorkspacesCustomConfigData = { body: WorkspaceCustomConfigPayload 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 5f8dd9ba6c5..ebb1872d5a9 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -586,6 +586,14 @@ export const zTriggerProviderErrorResponse = z.object({ error: z.string(), }) +/** + * WorkspaceCustomConfigResponse + */ +export const zWorkspaceCustomConfigResponse = z.object({ + remove_webapp_brand: z.boolean().nullish(), + replace_webapp_logo: z.string().nullish(), +}) + /** * WorkspaceCustomConfigPayload */ @@ -635,49 +643,6 @@ export const zTenantListResponse = z.object({ workspaces: z.array(zTenantListItemResponse), }) -/** - * WorkspaceCustomConfigResponse - */ -export const zWorkspaceCustomConfigResponse = z.object({ - remove_webapp_brand: z.boolean().nullish(), - replace_webapp_logo: z.string().nullish(), -}) - -/** - * TenantInfoResponse - */ -export const zTenantInfoResponse = z.object({ - created_at: z.int().nullish(), - custom_config: zWorkspaceCustomConfigResponse.nullish(), - id: z.string(), - in_trial: z.boolean().nullish(), - name: z.string().nullish(), - next_credit_reset_date: z.int().nullish(), - plan: z.string().nullish(), - role: z.string().nullish(), - status: z.string().nullish(), - trial_credits: z.int().nullish(), - trial_credits_exhausted_at: z.int().nullish(), - trial_credits_used: z.int().nullish(), - trial_end_reason: z.string().nullish(), -}) - -/** - * WorkspaceTenantResultResponse - */ -export const zWorkspaceTenantResultResponse = z.object({ - result: z.string(), - tenant: zTenantInfoResponse, -}) - -/** - * SwitchWorkspaceResponse - */ -export const zSwitchWorkspaceResponse = z.object({ - new_tenant: zTenantInfoResponse, - result: z.string(), -}) - /** * IconInfo * @@ -1266,6 +1231,22 @@ export const zWorkspaceAccessMatrix = z.object({ pagination: zPagination.nullish(), }) +/** + * TenantAccountRole + */ +export const zTenantAccountRole = z.enum(['admin', 'dataset_operator', 'editor', 'normal', 'owner']) + +/** + * CurrentWorkspaceSummaryResponse + */ +export const zCurrentWorkspaceSummaryResponse = z.object({ + credits: z.int().nullable(), + id: z.string(), + name: z.string(), + plan: z.string().nullable(), + role: zTenantAccountRole, +}) + /** * ToolEmojiIcon */ @@ -1575,6 +1556,41 @@ export const zTriggerProviderSubscriptionListResponse = z.array( zTriggerProviderSubscriptionApiEntity, ) +/** + * TenantInfoResponse + */ +export const zTenantInfoResponse = z.object({ + created_at: z.int().nullish(), + custom_config: zWorkspaceCustomConfigResponse.nullish(), + id: z.string(), + in_trial: z.boolean().nullish(), + name: z.string().nullish(), + next_credit_reset_date: z.int().nullish(), + plan: z.string().nullish(), + role: z.string().nullish(), + status: z.string().nullish(), + trial_credits: z.int().nullish(), + trial_credits_exhausted_at: z.int().nullish(), + trial_credits_used: z.int().nullish(), + trial_end_reason: z.string().nullish(), +}) + +/** + * WorkspaceTenantResultResponse + */ +export const zWorkspaceTenantResultResponse = z.object({ + result: z.string(), + tenant: zTenantInfoResponse, +}) + +/** + * SwitchWorkspaceResponse + */ +export const zSwitchWorkspaceResponse = z.object({ + new_tenant: zTenantInfoResponse, + result: z.string(), +}) + /** * PluginDependencyType */ @@ -3483,11 +3499,6 @@ export const zAccountWithRoleListResponseWritable = z.object({ */ export const zGetWorkspacesResponse = zTenantListResponse -/** - * Success - */ -export const zPostWorkspacesCurrentResponse = zTenantInfoResponse - export const zGetWorkspacesCurrentAgentProviderByProviderNamePath = z.object({ provider_name: z.string(), }) @@ -4797,6 +4808,11 @@ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdR */ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix +/** + * Success + */ +export const zGetWorkspacesCurrentSummaryResponse = zCurrentWorkspaceSummaryResponse + /** * Tool labels retrieved successfully */ @@ -5350,6 +5366,11 @@ export const zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsU */ export const zGetWorkspacesCurrentTriggersResponse = zTriggerProviderListResponse +/** + * Success + */ +export const zGetWorkspacesCustomConfigResponse = zWorkspaceCustomConfigResponse + export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload /** diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index f4d9e0e038a..171068e32f9 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -54,8 +54,10 @@ vi.mock('@/service/server', () => ({ serverConsoleQuery: { workspaces: { current: { - post: { - queryOptions: (...args: unknown[]) => mocks.workspaceQueryOptions(...args), + summary: { + get: { + queryOptions: (...args: unknown[]) => mocks.workspaceQueryOptions(...args), + }, }, rbac: { myPermissions: { @@ -97,7 +99,13 @@ describe('CommonLayoutHydrationBoundary', () => { currentEnv: 'DEVELOPMENT', }, }) - mocks.workspaceQueryFn.mockResolvedValue({ id: 'workspace-id', name: 'Workspace' }) + mocks.workspaceQueryFn.mockResolvedValue({ + id: 'workspace-id', + name: 'Workspace', + role: 'owner', + plan: 'sandbox', + credits: 200, + }) mocks.permissionQueryFn.mockResolvedValue({ workspace: { permission_keys: ['agent.manage'] }, app: { default_permission_keys: [], overrides: [] }, @@ -108,7 +116,7 @@ describe('CommonLayoutHydrationBoundary', () => { csrfToken: 'csrf-token', }) mocks.workspaceQueryOptions.mockReturnValue({ - queryKey: ['console', 'workspaces', 'current', 'post'], + queryKey: ['console', 'workspaces', 'current', 'summary', 'get'], queryFn: mocks.workspaceQueryFn, retry: false, }) @@ -166,7 +174,7 @@ describe('CommonLayoutHydrationBoundary', () => { expect(queryKeys).toEqual( expect.arrayContaining([ ['common', 'user-profile'], - ['console', 'workspaces', 'current', 'post'], + ['console', 'workspaces', 'current', 'summary', 'get'], [['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'], { type: 'query' }], ]), ) diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx index 17d5d1e8be9..7993a42848f 100644 --- a/web/app/(commonLayout)/hydration-boundary.tsx +++ b/web/app/(commonLayout)/hydration-boundary.tsx @@ -65,7 +65,7 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re await Promise.all([ queryClient.fetchQuery(serverUserProfileQueryOptions()), queryClient.prefetchQuery( - serverConsoleQuery.workspaces.current.post.queryOptions({ + serverConsoleQuery.workspaces.current.summary.get.queryOptions({ context, retry: false, }), diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 09a88bc054b..11e666e9652 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -7,7 +7,8 @@ import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/client' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import CustomPage from '../index' vi.mock('@/config', async (importOriginal) => { @@ -17,8 +18,15 @@ vi.mock('@/config', async (importOriginal) => { } }) -const render = (ui: ReactElement) => - renderWithConsoleQuery(ui, { +const render = (ui: ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(consoleQuery.workspaces.customConfig.get.queryKey(), { + remove_webapp_brand: false, + replace_webapp_logo: null, + }) + + return renderWithConsoleQuery(ui, { + queryClient, systemFeatures: { deployment_edition: 'CLOUD', branding: { @@ -27,6 +35,7 @@ const render = (ui: ReactElement) => }, }, }) +} const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { diff --git a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx index 78bc189b9af..beae7f08f0f 100644 --- a/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/__tests__/index.spec.tsx @@ -18,6 +18,7 @@ const createHookState = ( uploading: false, webappLogo: 'https://example.com/replace.png', webappBrandRemoved: false, + isCustomConfigUnavailable: false, uploadDisabled: false, workspaceLogo: 'https://example.com/workspace-logo.png', isSandbox: false, @@ -106,6 +107,18 @@ describe('CustomWebAppBrand', () => { expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled() }) + it('should disable brand edits while the custom config is unavailable', () => { + renderComponent({ + fileId: 'new-logo', + isCustomConfigUnavailable: true, + uploadDisabled: true, + }) + + expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByRole('button', { name: 'custom.change' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled() + }) + it('should default the switch to unchecked when brand removal state is missing', () => { const { container } = renderComponent({ webappBrandRemoved: undefined, diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index 2ee2c070bcf..5141dbd1523 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -1,22 +1,30 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' import type { ChangeEvent } from 'react' -import type { ICurrentWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' -import { act } from '@testing-library/react' +import { act, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' -import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from '@/context/app-context-defaults' +import { initialLangGeniusVersionInfo } from '@/context/app-context-defaults' import { useProviderContext } from '@/context/provider-context' -import { updateCurrentWorkspace } from '@/service/common' -import { renderHookWithConsoleQuery } from '@/test/console/query-data' +import { createConsoleQueryClient, renderHookWithConsoleQuery } from '@/test/console/query-data' import useWebAppBrand from '../use-web-app-brand' let currentBrandingOverrides: Partial = {} -const renderHook = (callback: (props: Props) => Result) => - renderHookWithConsoleQuery(callback, { +let customConfig = { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: false, +} +let seedCustomConfig = true +let customConfigQueryPending = false +let customConfigQueryError: Error | undefined +const renderHook = (callback: (props: Props) => Result) => { + const queryClient = createConsoleQueryClient() + if (seedCustomConfig) queryClient.setQueryData(['custom-config'], customConfig) + + return renderHookWithConsoleQuery(callback, { systemFeatures: { branding: { enabled: true, @@ -24,7 +32,9 @@ const renderHook = (callback: (props: Props) => Result) => ...currentBrandingOverrides, }, }, + queryClient, }) +} const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() @@ -42,14 +52,54 @@ const { mockNotify, mockToast } = vi.hoisted(() => { const consoleStateRef = vi.hoisted(() => ({ value: undefined as ConsoleStateFixture | undefined, })) -const mockConsoleStateReader = vi.hoisted(() => vi.fn()) +const mockUpdateCustomConfig = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) -vi.mock('@/service/common', () => ({ - updateCurrentWorkspace: vi.fn(), -})) +vi.mock('@/service/client', async (importOriginal) => { + const actual = await importOriginal() + const consoleQuery = new Proxy(actual.consoleQuery, { + get(target, prop, receiver) { + if (prop === 'workspaces') { + return new Proxy(target.workspaces, { + get(workspacesTarget, workspaceProp, workspaceReceiver) { + if (workspaceProp === 'customConfig') { + return { + get: { + key: () => ['custom-config'], + queryOptions: () => ({ + queryKey: ['custom-config'], + queryFn: async () => { + if (customConfigQueryPending) return new Promise(() => {}) + if (customConfigQueryError) + return Promise.reject(new Error(customConfigQueryError.message)) + return customConfig + }, + }), + }, + post: { + mutationOptions: () => ({ + mutationFn: (variables: unknown) => mockUpdateCustomConfig(variables), + }), + }, + } + } + + return Reflect.get(workspacesTarget, workspaceProp, workspaceReceiver) + }, + }) + } + + return Reflect.get(target, prop, receiver) + }, + }) + + return { + ...actual, + consoleQuery, + } +}) vi.mock('@/context/account-state', async () => { const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') return createAccountStateModuleMock(() => ({ @@ -87,7 +137,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({ getImageUploadErrorMessage: vi.fn(), })) -const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) const mockUseProviderContext = vi.mocked(useProviderContext) const mockImageUpload = vi.mocked(imageUpload) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) @@ -118,19 +167,6 @@ const createProviderContext = ({ } const createConsoleState = (overrides: Partial = {}): ConsoleStateFixture => { - const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides - const workspaceOverrides: Partial = - currentWorkspaceOverride ?? {} - const currentWorkspace = { - ...initialWorkspaceInfo, - ...workspaceOverrides, - custom_config: { - replace_webapp_logo: 'https://example.com/replace.png', - remove_webapp_brand: false, - ...workspaceOverrides.custom_config, - }, - } - return { userProfile: testUserProfile, refreshUserProfile: vi.fn(), @@ -142,15 +178,12 @@ const createConsoleState = (overrides: Partial = {}): Conso refreshCurrentWorkspace: vi.fn(), langGeniusVersionInfo: initialLangGeniusVersionInfo, isLoadingCurrentWorkspace: false, - ...restOverrides, - currentWorkspace, + ...overrides, } } describe('useWebAppBrand', () => { - let consoleState: ConsoleStateFixture const setConsoleState = (nextValue: ConsoleStateFixture) => { - consoleState = nextValue consoleStateRef.value = nextValue } @@ -160,8 +193,14 @@ describe('useWebAppBrand', () => { setConsoleState(createConsoleState()) currentBrandingOverrides = {} - mockUpdateCurrentWorkspace.mockResolvedValue(consoleState.currentWorkspace as ICurrentWorkspace) - mockConsoleStateReader.mockImplementation(() => consoleState) + customConfig = { + replace_webapp_logo: 'https://example.com/replace.png', + remove_webapp_brand: false, + } + seedCustomConfig = true + customConfigQueryPending = false + customConfigQueryError = undefined + mockUpdateCustomConfig.mockResolvedValue(customConfig) mockUseProviderContext.mockReturnValue(createProviderContext()) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -213,17 +252,7 @@ describe('useWebAppBrand', () => { planType: Plan.sandbox, }), ) - setConsoleState( - createConsoleState({ - currentWorkspace: { - ...initialWorkspaceInfo, - custom_config: { - replace_webapp_logo: 'https://example.com/replace.png', - remove_webapp_brand: true, - }, - }, - }), - ) + customConfig = { ...customConfig, remove_webapp_brand: true } const { result } = renderHook(() => useWebAppBrand()) @@ -240,18 +269,35 @@ describe('useWebAppBrand', () => { expect(result.current.workspaceLogo).toBe('') }) - it('should fall back to an empty custom logo when custom config is missing', () => { - setConsoleState({ - ...createConsoleState(), - currentWorkspace: { - ...initialWorkspaceInfo, - }, - }) + it('should fall back to an empty custom logo when the custom config has no logo', () => { + customConfig = { replace_webapp_logo: '', remove_webapp_brand: false } const { result } = renderHook(() => useWebAppBrand()) expect(result.current.webappLogo).toBe('') }) + + it('should disable brand edits while the custom config is loading', () => { + seedCustomConfig = false + customConfigQueryPending = true + + const { result } = renderHook(() => useWebAppBrand()) + + expect(result.current.isCustomConfigUnavailable).toBe(true) + expect(result.current.uploadDisabled).toBe(true) + }) + + it('should disable brand edits when the custom config request fails', async () => { + seedCustomConfig = false + customConfigQueryError = new Error('custom config unavailable') + + const { result } = renderHook(() => useWebAppBrand()) + + await waitFor(() => { + expect(result.current.isCustomConfigUnavailable).toBe(true) + }) + expect(result.current.uploadDisabled).toBe(true) + }) }) // State transitions driven by user actions. @@ -348,12 +394,6 @@ describe('useWebAppBrand', () => { }) it('should persist the selected logo and reset transient state on apply', async () => { - const mutateCurrentWorkspace = vi.fn() - setConsoleState( - createConsoleState({ - refreshCurrentWorkspace: mutateCurrentWorkspace, - }), - ) mockImageUpload.mockImplementation(({ onSuccessCallback }) => { onSuccessCallback({ id: 'new-logo' }) }) @@ -373,64 +413,44 @@ describe('useWebAppBrand', () => { await result.current.handleApply() }) - expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ - url: '/workspaces/custom-config', + expect(mockUpdateCustomConfig).toHaveBeenCalledWith({ body: { remove_webapp_brand: false, replace_webapp_logo: 'new-logo', }, }) - expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) expect(result.current.fileId).toBe('') expect(result.current.imgKey).toBe(previousImgKey + 1) dateNowSpy.mockRestore() }) it('should restore the default branding configuration', async () => { - const mutateCurrentWorkspace = vi.fn() - setConsoleState( - createConsoleState({ - refreshCurrentWorkspace: mutateCurrentWorkspace, - }), - ) - const { result } = renderHook(() => useWebAppBrand()) await act(async () => { await result.current.handleRestore() }) - expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ - url: '/workspaces/custom-config', + expect(mockUpdateCustomConfig).toHaveBeenCalledWith({ body: { remove_webapp_brand: false, replace_webapp_logo: '', }, }) - expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) }) it('should persist brand removal changes', async () => { - const mutateCurrentWorkspace = vi.fn() - setConsoleState( - createConsoleState({ - refreshCurrentWorkspace: mutateCurrentWorkspace, - }), - ) - const { result } = renderHook(() => useWebAppBrand()) await act(async () => { await result.current.handleSwitch(true) }) - expect(mockUpdateCurrentWorkspace).toHaveBeenCalledWith({ - url: '/workspaces/custom-config', + expect(mockUpdateCustomConfig).toHaveBeenCalledWith({ body: { remove_webapp_brand: true, }, }) - expect(mutateCurrentWorkspace).toHaveBeenCalledTimes(1) }) it('should clear temporary upload state on cancel', () => { diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index 1379f022722..32bcce72f4b 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -1,46 +1,50 @@ +import type { WorkspaceCustomConfigPayload } from '@dify/contracts/api/console/workspaces/types.gen' import type { ChangeEvent } from 'react' import { toast } from '@langgenius/dify-ui/toast' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue, useSetAtom } from 'jotai' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { Plan } from '@/app/components/billing/type' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' -import { currentWorkspaceAtom, refreshCurrentWorkspaceAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { updateCurrentWorkspace } from '@/service/common' +import { consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 -const CUSTOM_CONFIG_URL = '/workspaces/custom-config' const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload' const useWebAppBrand = () => { const { t } = useTranslation() const { plan, enableBilling } = useProviderContext() - const currentWorkspace = useAtomValue(currentWorkspaceAtom) - const mutateCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom) + const queryClient = useQueryClient() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [fileId, setFileId] = useState('') const [imgKey, setImgKey] = useState(() => Date.now()) const [uploadProgress, setUploadProgress] = useState(0) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const customConfigQuery = useQuery(consoleQuery.workspaces.customConfig.get.queryOptions()) + const { data: customConfig } = customConfigQuery + const updateCustomConfigMutation = useMutation( + consoleQuery.workspaces.customConfig.post.mutationOptions(), + ) const isSandbox = enableBilling && plan.type === Plan.sandbox const uploading = uploadProgress > 0 && uploadProgress < 100 - const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' - const webappBrandRemoved = currentWorkspace.custom_config?.remove_webapp_brand + const webappLogo = customConfig?.replace_webapp_logo || '' + const webappBrandRemoved = customConfig?.remove_webapp_brand ?? undefined const canManageCustomBrand = hasPermission(workspacePermissionKeys, 'customization.manage') - const uploadDisabled = isSandbox || webappBrandRemoved || !canManageCustomBrand + const isCustomConfigUnavailable = customConfigQuery.isPending || customConfigQuery.isError + const uploadDisabled = + isCustomConfigUnavailable || isSandbox || webappBrandRemoved || !canManageCustomBrand const workspaceLogo = systemFeatures.branding.enabled ? systemFeatures.branding.workspace_logo : '' - const persistWorkspaceBrand = async (body: Record) => { - await updateCurrentWorkspace({ - url: CUSTOM_CONFIG_URL, - body, + const persistWorkspaceBrand = async (body: WorkspaceCustomConfigPayload) => { + await updateCustomConfigMutation.mutateAsync({ body }) + await queryClient.invalidateQueries({ + queryKey: consoleQuery.workspaces.customConfig.get.key(), }) - mutateCurrentWorkspace() } const handleChange = (e: ChangeEvent) => { const file = e.target.files?.[0] @@ -101,6 +105,7 @@ const useWebAppBrand = () => { uploading, webappLogo, webappBrandRemoved, + isCustomConfigUnavailable, uploadDisabled, workspaceLogo, isSandbox, diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index 4efd00f3353..a76ca4eb1af 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -18,6 +18,7 @@ const CustomWebAppBrand = () => { uploading, webappLogo, webappBrandRemoved, + isCustomConfigUnavailable, uploadDisabled, workspaceLogo, canManageCustomBrand, @@ -36,7 +37,7 @@ const CustomWebAppBrand = () => { @@ -105,7 +106,7 @@ const CustomWebAppBrand = () => { variant="primary" className="mr-2" onClick={handleApply} - disabled={webappBrandRemoved || !canManageCustomBrand} + disabled={isCustomConfigUnavailable || webappBrandRemoved || !canManageCustomBrand} > {t(($) => $.apply, { ns: 'custom' })} 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 21fcfb4a24d..38ca205c9d0 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -122,6 +122,11 @@ vi.mock('@/app/components/billing/billing-page', () => ({ default: () => , })) +vi.mock('@/app/components/custom/custom-page', () => ({ + __esModule: true, + default: () => custom.custom, +})) + vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({ __esModule: true, default: () => , @@ -151,13 +156,7 @@ const baseConsoleState: ConsoleStateFixture = { id: '1', name: 'Workspace', plan: '', - status: '', - created_at: 0, role: 'owner', - providers: [], - trial_credits: 0, - trial_credits_used: 0, - next_credit_reset_date: 0, }, isCurrentWorkspaceManager: true, isCurrentWorkspaceOwner: true, diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 1af6135d86d..c1a38de58cc 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' import type { Role } from '@/models/access-control' -import type { ICurrentWorkspace, Member } from '@/models/common' +import type { Member } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -227,7 +227,7 @@ describe('MembersPage', () => { setConsoleState({ userProfile: { email: 'owner@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'owner' }, isCurrentWorkspaceOwner: true, isCurrentWorkspaceManager: true, workspacePermissionKeys: ['workspace.member.manage'], @@ -346,7 +346,7 @@ describe('MembersPage', () => { it('should hide manager controls for non-owner non-manager users', () => { setConsoleState({ userProfile: { email: 'admin@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'admin' }, isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: false, } as unknown as ConsoleStateFixture) @@ -459,7 +459,7 @@ describe('MembersPage', () => { it('should show invite button when user is manager but not owner', () => { setConsoleState({ userProfile: { email: 'admin@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'admin' }, isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: true, workspacePermissionKeys: ['workspace.member.manage'], @@ -474,7 +474,7 @@ describe('MembersPage', () => { it('should allow admins to operate other non-owner members only', () => { setConsoleState({ userProfile: { email: 'admin@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'admin' }, isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: true, workspacePermissionKeys: ['workspace.member.manage'], @@ -580,7 +580,7 @@ describe('MembersPage', () => { it('should render role badge names from account roles', () => { setConsoleState({ userProfile: { email: 'admin@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'admin' }, isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: false, } as unknown as ConsoleStateFixture) @@ -651,7 +651,7 @@ describe('MembersPage', () => { const user = userEvent.setup() setConsoleState({ userProfile: { email: 'admin@example.com' }, - currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace', role: 'admin' }, isCurrentWorkspaceOwner: false, isCurrentWorkspaceManager: true, workspacePermissionKeys: ['workspace.member.manage'], diff --git a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx index 2b42dd28f48..d64395e164e 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx @@ -1,4 +1,3 @@ -import type { ICurrentWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { screen } from '@testing-library/react' import { vi } from 'vitest' @@ -39,7 +38,7 @@ describe('InviteButton', () => { beforeEach(() => { vi.clearAllMocks() mockConsoleStateReader.mockReturnValue({ - currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, + currentWorkspace: { id: 'workspace-id' }, } as unknown as ConsoleStateFixture) }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 9e8af9baa62..275554eebf8 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { ICurrentWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -43,7 +42,7 @@ describe('EditWorkspaceModal', () => { vi.clearAllMocks() const consoleState = { - currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace' }, isCurrentWorkspaceOwner: true, } as unknown as ConsoleStateFixture mockConsoleState.current = consoleState @@ -93,7 +92,10 @@ describe('EditWorkspaceModal', () => { assign: mockAssign, origin: 'http://localhost', }) - vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace) + vi.mocked(updateWorkspaceInfo).mockResolvedValue({ + result: 'success', + tenant: { id: 'workspace-id' }, + }) renderModal() @@ -170,7 +172,7 @@ describe('EditWorkspaceModal', () => { it('should disable confirm button for non-owners', async () => { mockConsoleStateReader.mockReturnValue({ - currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace' }, isCurrentWorkspaceOwner: false, } as unknown as ConsoleStateFixture) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index f3193e7e1cf..1508dc1d7d8 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { ICurrentWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -53,7 +52,7 @@ describe('TransferOwnershipModal', () => { vi.clearAllMocks() const consoleState = { - currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, + currentWorkspace: { name: 'Test Workspace' }, userProfile: { email: 'owner@example.com', id: 'owner-id' }, } as unknown as ConsoleStateFixture mockConsoleState.current = consoleState diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 8f738b5e04c..506c6fcd7d3 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -3,12 +3,15 @@ import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, } from '@dify/contracts/api/console/onboarding/types.gen' +import type { + GetWorkspacesCurrentSummaryResponse, + TenantListItemResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import type { Mock } from 'vitest' import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' -import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { act, fireEvent, screen, waitFor } from '@testing-library/react' @@ -238,7 +241,7 @@ vi.mock('react-i18next', async () => { vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() - const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const + const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'summary', 'get'] as const const currentPermissionsQueryKey = [ ['console', 'workspaces', 'current', 'rbac', 'myPermissions', 'get'], { type: 'query' }, @@ -249,14 +252,16 @@ vi.mock('@/service/client', async (importOriginal) => { if (prop === 'workspaces') { return { current: { - post: { - key: () => currentWorkspaceQueryKey, - queryKey: () => currentWorkspaceQueryKey, - queryOptions: (options?: object) => ({ - queryKey: currentWorkspaceQueryKey, - queryFn: () => new Promise(() => {}), - ...options, - }), + summary: { + get: { + key: () => currentWorkspaceQueryKey, + queryKey: () => currentWorkspaceQueryKey, + queryOptions: (options?: object) => ({ + queryKey: currentWorkspaceQueryKey, + queryFn: () => new Promise(() => {}), + ...options, + }), + }, }, rbac: { myPermissions: { @@ -392,7 +397,7 @@ let mockPathname = '/apps' let mockInstalledApps: InstalledAppResponse[] = [] let mockInstalledAppsPending = false let mockInstalledAppsHasNextPage = false -let mockWorkspaces: IWorkspace[] = [] +let mockWorkspaces: TenantListItemResponse[] = [] const ownerWorkspacePermissionKeys = [ 'workspace.member.manage', @@ -450,13 +455,8 @@ const consoleState: ConsoleStateFixture = { id: 'workspace-1', name: 'Solar Studio', plan: Plan.team, - status: 'normal', - created_at: 0, + credits: 7500, role: 'owner', - providers: [], - trial_credits: 10000, - trial_credits_used: 2500, - next_credit_reset_date: 0, }, isCurrentWorkspaceManager: true, isCurrentWorkspaceOwner: true, @@ -497,8 +497,8 @@ const renderMainNav = ( const currentConsoleState = mockConsoleState.current ?? consoleState mockConsoleState.current = currentConsoleState queryClient.setQueryData( - consoleQuery.workspaces.current.post.queryKey(), - currentConsoleState.currentWorkspace as ICurrentWorkspace, + consoleQuery.workspaces.current.summary.get.queryKey(), + currentConsoleState.currentWorkspace as GetWorkspacesCurrentSummaryResponse, ) queryClient.setQueryData(userProfileQueryOptions().queryKey, { profile: { diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index f5d53158967..b83c4ebd0e1 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -1,7 +1,9 @@ -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { + GetWorkspacesCurrentSummaryResponse, + TenantListItemResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' -import type { IWorkspace } from '@/models/common' import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -25,7 +27,7 @@ const { } = vi.hoisted(() => ({ mockFetchWorkspaces: vi.fn(), mockSwitchWorkspace: vi.fn(), - mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, + mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'summary', 'get'] as const, mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const, })) const mockConsoleState = vi.hoisted(() => ({ @@ -58,14 +60,16 @@ vi.mock('@/service/client', async (importOriginal) => { if (prop === 'workspaces') { return { current: { - post: { - key: () => mockCurrentWorkspaceQueryKey, - queryKey: () => mockCurrentWorkspaceQueryKey, - queryOptions: (options?: object) => ({ - queryKey: mockCurrentWorkspaceQueryKey, - queryFn: () => new Promise(() => {}), - ...options, - }), + summary: { + get: { + key: () => mockCurrentWorkspaceQueryKey, + queryKey: () => mockCurrentWorkspaceQueryKey, + queryOptions: (options?: object) => ({ + queryKey: mockCurrentWorkspaceQueryKey, + queryFn: () => new Promise(() => {}), + ...options, + }), + }, }, }, get: { @@ -96,17 +100,12 @@ vi.mock('@/service/client', async (importOriginal) => { } }) -const currentWorkspaceValue: PostWorkspacesCurrentResponse = { +const currentWorkspaceValue: GetWorkspacesCurrentSummaryResponse = { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, - status: 'normal', - created_at: 0, role: 'owner', - trial_credits: 10000, - trial_credits_used: 2500, - trial_credits_exhausted_at: 0, - next_credit_reset_date: 0, + credits: 7500, } const mockSetShowPricingModal = vi.fn() @@ -115,11 +114,11 @@ vi.mock('nuqs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } }) -let mockCurrentWorkspace: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue -let mockWorkspaces: IWorkspace[] = [] +let mockCurrentWorkspace: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue +let mockWorkspaces: TenantListItemResponse[] = [] const mockCurrentWorkspaceQuery = ( - data: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue, + data: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue, isPending = false, ) => { mockCurrentWorkspace = isPending ? undefined : data @@ -134,7 +133,10 @@ const renderWorkspaceCard = (options?: RenderWorkspaceCardOptions) => { const { seedWorkspaces = true, systemFeaturesLicense, ...renderOptions } = options ?? {} const queryClient = createConsoleQueryClient() if (mockCurrentWorkspace) - queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), mockCurrentWorkspace) + queryClient.setQueryData( + consoleQuery.workspaces.current.summary.get.queryKey(), + mockCurrentWorkspace, + ) if (seedWorkspaces) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) if (systemFeaturesLicense) seedSystemFeaturesLicense(queryClient, systemFeaturesLicense) @@ -209,6 +211,24 @@ describe('WorkspaceCard', () => { ).toHaveAttribute('href', '/integrations/model-provider') }) + it('renders unlimited credits from the summary contract', () => { + mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, credits: -1 }) + + renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) + + expect(screen.getByText('common.license.unlimited')).toBeInTheDocument() + }) + + it('hides the credits link when the summary has no effective credits', () => { + mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, credits: null }) + + renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) + + expect( + screen.queryByRole('link', { name: /common\.mainNav\.workspace\.credits/ }), + ).not.toBeInTheDocument() + }) + it('renders a stable skeleton while the current workspace is loading', () => { mockCurrentWorkspaceQuery(undefined, true) diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index 53a7159247c..9c43f0621d6 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -1,6 +1,6 @@ 'use client' -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' @@ -25,7 +25,7 @@ import Link from '@/next/link' import { consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' import { basePath } from '@/utils/var' -import { formatCredits, getRemainingCredits } from '../utils' +import { formatCredits } from '../utils' import { WorkspaceMenuItemContent } from './workspace-menu-content' import WorkspacePlanBadge from './workspace-plan-badge' import { WorkspaceSwitcher } from './workspace-switcher' @@ -73,13 +73,13 @@ function WorkspaceCardSkeleton({ ) } -function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit: string }) { - const label = `${credits} ${unit}` +function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit?: string }) { + const label = [credits, unit].filter(Boolean).join(' ') return ( {credits} - {unit} + {unit && {unit}} ) } @@ -97,7 +97,7 @@ function WorkspaceCardTrigger({ }: { name: string status: ReactNode - credits: number + credits: number | null showCloudBilling: boolean showPlanAction: boolean planActionLabel: string @@ -107,7 +107,12 @@ function WorkspaceCardTrigger({ }) { const { t } = useTranslation() const creditsUnit = t(($) => $['mainNav.workspace.creditsUnit'], { ns: 'common' }) - const formattedCredits = formatCredits(credits) + const isUnlimited = credits === -1 + const formattedCredits = isUnlimited + ? t(($) => $['license.unlimited'], { ns: 'common' }) + : credits === null + ? '' + : formatCredits(credits) const showStatus = status !== undefined && status !== null return ( @@ -142,14 +147,23 @@ function WorkspaceCardTrigger({ {showCloudBilling && ( - $['mainNav.workspace.credits'], { ns: 'common', count: credits })} - > - - - + {credits !== null && ( + $['mainNav.workspace.credits'], { ns: 'common', count: credits }) + } + > + + + + )} {showPlanAction && ( const selectCurrentWorkspaceCardData = (workspace: CurrentWorkspaceCardSource) => ({ id: workspace.id, name: workspace.name, plan: workspace.plan, - credits: getRemainingCredits(workspace.trial_credits ?? 0, workspace.trial_credits_used ?? 0), + credits: workspace.credits, }) export function WorkspaceCard() { @@ -247,7 +261,7 @@ export function WorkspaceCard() { select: ({ deployment_edition }) => deployment_edition, }) const currentWorkspaceQuery = useQuery( - consoleQuery.workspaces.current.post.queryOptions({ + consoleQuery.workspaces.current.summary.get.queryOptions({ select: selectCurrentWorkspaceCardData, }), ) diff --git a/web/app/components/main-nav/utils.ts b/web/app/components/main-nav/utils.ts index 93834ee7841..caa0784b8a2 100644 --- a/web/app/components/main-nav/utils.ts +++ b/web/app/components/main-nav/utils.ts @@ -1,3 +1 @@ -export const getRemainingCredits = (total: number, used: number) => Math.max(total - used, 0) - export const formatCredits = (value: number) => new Intl.NumberFormat().format(value) diff --git a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx index 8fc30e513ba..7d247140e87 100644 --- a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx +++ b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx @@ -2,8 +2,8 @@ import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, } from '@dify/contracts/api/console/onboarding/types.gen' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { StepByStepTourSessionState, StepByStepTourTaskId } from '../types' -import type { ICurrentWorkspace } from '@/models/common' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' @@ -33,8 +33,6 @@ type StepByStepTourFixtureState = StepByStepTourSessionState & { updatedAt?: string | null } -type WorkspaceRole = ICurrentWorkspace['role'] - const mockRouterPush = vi.fn() const mockTrackEvent = vi.hoisted(() => vi.fn()) let mockPathname = '/apps' @@ -54,7 +52,7 @@ const mockIsCurrentWorkspaceManager = vi.hoisted(() => ({ value: true, })) const mockCurrentWorkspaceRole = vi.hoisted(() => ({ - value: 'owner' as WorkspaceRole, + value: 'owner' as GetWorkspacesCurrentSummaryResponse['role'], })) const mockEnableLearnApp = vi.hoisted(() => ({ value: true, @@ -404,13 +402,7 @@ function getMockAppContextState() { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, - status: 'normal', role: mockCurrentWorkspaceRole.value, - created_at: 0, - providers: [], - trial_credits: 0, - trial_credits_used: 0, - next_credit_reset_date: 0, }, isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager.value, workspacePermissionKeys: mockWorkspacePermissionKeys.value, diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx index c8a211dd6e8..fab990c6cd6 100644 --- a/web/app/device/page.tsx +++ b/web/app/device/page.tsx @@ -53,8 +53,10 @@ export default function DevicePage() { refetchOnMount: false, }) const account = userResp?.profile - const { data: currentWorkspace } = useQuery({ - ...consoleQuery.workspaces.current.post.queryOptions(), + const { data: currentWorkspaceName } = useQuery({ + ...consoleQuery.workspaces.current.summary.get.queryOptions({ + select: (workspace) => workspace.name, + }), enabled: !!account && !profileErr, retry: false, refetchOnWindowFocus: false, @@ -175,7 +177,7 @@ export default function DevicePage() { accountEmail={account?.email} accountName={account?.name} accountAvatarUrl={account?.avatar_url ?? null} - defaultWorkspace={currentWorkspace?.name ?? undefined} + defaultWorkspace={currentWorkspaceName ?? undefined} onApproved={() => setView({ kind: 'success' })} onDenied={() => setView({ kind: 'error_expired' })} onError={(e) => setErrMsg(e)} diff --git a/web/app/education-apply/applied-education-content.tsx b/web/app/education-apply/applied-education-content.tsx index 8d74ba5273a..26042af9687 100644 --- a/web/app/education-apply/applied-education-content.tsx +++ b/web/app/education-apply/applied-education-content.tsx @@ -1,9 +1,11 @@ 'use client' -import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { + GetWorkspacesCurrentSummaryResponse, + TenantListItemResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import type { Plan as PlanType } from '@/app/components/billing/type' -import type { ICurrentWorkspace } from '@/models/common' import { Select, SelectTrigger } from '@langgenius/dify-ui/select' import { useTranslation } from 'react-i18next' import { Plan } from '@/app/components/billing/type' @@ -12,7 +14,7 @@ import { PlanBadge } from '@/app/components/header/plan-badge' type AppliedEducationContentProps = { workspaces: TenantListItemResponse[] - currentWorkspace: ICurrentWorkspace + currentWorkspace: GetWorkspacesCurrentSummaryResponse plan: PlanType action: ReactNode isSwitchingWorkspace: boolean diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index f26ed3ee8f8..60b938e88b4 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -1,8 +1,8 @@ 'use client' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import type { Plan as PlanType } from '@/app/components/billing/type' -import type { ICurrentWorkspace } from '@/models/common' import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { toast } from '@langgenius/dify-ui/toast' @@ -292,7 +292,7 @@ const EducationApplyAgeContent = () => { } type AppliedEducationWorkspaceBlockProps = { - currentWorkspace: ICurrentWorkspace + currentWorkspace: GetWorkspacesCurrentSummaryResponse plan: PlanType action: ReactNode isSwitchingWorkspace: boolean diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index f1e9dce2a2b..512d87902af 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -14,7 +14,7 @@ import { setZendeskConversationFields } from '@/app/components/base/zendesk/util import { ZENDESK_FIELD_IDS } from '@/config' import { createSystemFeaturesFixture } from '@/test/console/system-features' import { refreshUserProfileAtom, userProfileAtom } from '../account-state' -import { initialWorkspaceInfo } from '../app-context-defaults' +import { initialWorkspaceSummary } from '../app-context-defaults' import { datasetDefaultPermissionKeysAtom, refreshWorkspacePermissionKeysAfterMutationDenialAtom, @@ -29,7 +29,6 @@ import { isCurrentWorkspaceEditorAtom, isCurrentWorkspaceManagerAtom, isCurrentWorkspaceOwnerAtom, - refreshCurrentWorkspaceAtom, } from '../workspace-state' const mockGetRequest = vi.hoisted(() => vi.fn()) @@ -43,13 +42,8 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({ id: 'workspace-1', name: 'Workspace', plan: 'sandbox', - status: 'normal', - created_at: 1704067200, + credits: 200, role: 'editor', - trial_credits: 200, - trial_credits_used: 0, - next_credit_reset_date: 1706745600, - custom_config: {}, })) const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({ data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined, @@ -143,19 +137,21 @@ vi.mock('@/service/client', () => ({ }, workspaces: { current: { - post: { - key: () => ['current-workspace'], - queryOptions: (options: { - select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown - }) => ({ - queryKey: ['current-workspace'], - queryFn: async () => { - if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {}) + summary: { + get: { + key: () => ['current-workspace-summary'], + queryOptions: (options: { + select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown + }) => ({ + queryKey: ['current-workspace-summary'], + queryFn: async () => { + if (mockCurrentWorkspaceQueryState.isPending) return new Promise(() => {}) - return mockCurrentWorkspaceQueryState.data - }, - ...options, - }), + return mockCurrentWorkspaceQueryState.data + }, + ...options, + }), + }, }, rbac: { myPermissions: { @@ -227,7 +223,6 @@ function ConsoleBootstrapProbe() { const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const refreshUserProfile = useSetAtom(refreshUserProfileAtom) - const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom) const refreshPermissionsAfterMutationDenial = useSetAtom( refreshWorkspacePermissionKeysAfterMutationDenialAtom, ) @@ -286,9 +281,6 @@ function ConsoleBootstrapProbe() { refresh user - - refresh workspace - void refreshPermissionsAfterMutationDenial()}> refresh permissions after denial @@ -425,8 +417,8 @@ describe('Console bootstrap', () => { renderConsoleBootstrap() expect(await screen.findByText('user:user@example.com')).toBeInTheDocument() - expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument() - expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument() + expect(screen.getByText(`workspace:${initialWorkspaceSummary.name}`)).toBeInTheDocument() + expect(screen.getByText(`role:${initialWorkspaceSummary.role}`)).toBeInTheDocument() expect(screen.getByText('keys:')).toBeInTheDocument() expect(screen.getByText('dataset keys:')).toBeInTheDocument() expect(screen.getByText('version://')).toBeInTheDocument() @@ -440,7 +432,7 @@ describe('Console bootstrap', () => { renderConsoleBootstrap() - expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument() + expect(await screen.findByText(`role:${initialWorkspaceSummary.role}`)).toBeInTheDocument() }) it('should derive role flags from the current workspace role', async () => { @@ -469,15 +461,13 @@ describe('Console bootstrap', () => { }) describe('Refresh actions', () => { - it('should invalidate the source queries when refresh actions are called', async () => { + it('should invalidate the user profile query when refresh is called', async () => { const { queryClient } = renderConsoleBootstrap() const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries') fireEvent.click(await screen.findByRole('button', { name: /refresh user/i })) - fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i })) expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] }) - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] }) }) it('starts a fresh permission request without waiting for an older request', async () => { diff --git a/web/context/amplitude-identity-sync.ts b/web/context/amplitude-identity-sync.ts index abd7ac462a3..bbdb3c8e23c 100644 --- a/web/context/amplitude-identity-sync.ts +++ b/web/context/amplitude-identity-sync.ts @@ -1,7 +1,7 @@ 'use client' import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' -import type { ICurrentWorkspace } from '@/models/common' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { atom } from 'jotai' import { atomEffect } from 'jotai-effect' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' @@ -17,7 +17,7 @@ function buildAmplitudeProperties({ currentWorkspace, userProfile, }: { - currentWorkspace: ICurrentWorkspace + currentWorkspace: GetWorkspacesCurrentSummaryResponse userProfile: GetAccountProfileResponse }) { const properties: AmplitudeProperties = { @@ -29,7 +29,7 @@ function buildAmplitudeProperties({ if (currentWorkspace.id) { properties.workspace_id = currentWorkspace.id properties.workspace_name = currentWorkspace.name - properties.workspace_plan = currentWorkspace.plan + if (currentWorkspace.plan) properties.workspace_plan = currentWorkspace.plan properties.workspace_role = currentWorkspace.role } diff --git a/web/context/app-context-defaults.ts b/web/context/app-context-defaults.ts index 56a5e37613b..a7f2b909797 100644 --- a/web/context/app-context-defaults.ts +++ b/web/context/app-context-defaults.ts @@ -1,5 +1,5 @@ +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { LangGeniusVersionInfo } from './app-context-types' -import type { ICurrentWorkspace } from '@/models/common' export const initialLangGeniusVersionInfo: LangGeniusVersionInfo = { current_env: '', @@ -15,16 +15,10 @@ export const initialLangGeniusVersionInfo: LangGeniusVersionInfo = { can_auto_update: false, } -export const initialWorkspaceInfo: ICurrentWorkspace = { +export const initialWorkspaceSummary: GetWorkspacesCurrentSummaryResponse = { id: '', name: '', - plan: '', - status: '', - created_at: 0, + plan: null, + credits: null, role: 'normal', - providers: [], - trial_credits: 200, - trial_credits_used: 0, - trial_credits_exhausted_at: 0, - next_credit_reset_date: 0, } diff --git a/web/context/app-context-normalizers.ts b/web/context/app-context-normalizers.ts index 40d70771dc6..ab491ca5766 100644 --- a/web/context/app-context-normalizers.ts +++ b/web/context/app-context-normalizers.ts @@ -1,10 +1,9 @@ import type { GetVersionResponse } from '@dify/contracts/api/console/version/types.gen' -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { LangGeniusVersionInfo } from './app-context-types' -import type { ICurrentWorkspace } from '@/models/common' -import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context-defaults' +import { initialLangGeniusVersionInfo, initialWorkspaceSummary } from './app-context-defaults' -const workspaceRoles = new Set([ +const workspaceRoles = new Set([ 'owner', 'admin', 'editor', @@ -27,44 +26,30 @@ export type ProfileMeta = { } function resolveWorkspaceRole( - role: PostWorkspacesCurrentResponse['role'], -): ICurrentWorkspace['role'] { - if (role && workspaceRoles.has(role as ICurrentWorkspace['role'])) - return role as ICurrentWorkspace['role'] + role: GetWorkspacesCurrentSummaryResponse['role'], +): GetWorkspacesCurrentSummaryResponse['role'] { + if (workspaceRoles.has(role)) return role - return initialWorkspaceInfo.role + return initialWorkspaceSummary.role } -export function normalizeCurrentWorkspace( - workspace?: PostWorkspacesCurrentResponse, -): ICurrentWorkspace { - if (!workspace) return initialWorkspaceInfo +export function normalizeCurrentWorkspaceSummary( + workspace?: GetWorkspacesCurrentSummaryResponse, +): GetWorkspacesCurrentSummaryResponse { + if (!workspace) return initialWorkspaceSummary return { id: workspace.id, - name: workspace.name ?? initialWorkspaceInfo.name, - plan: workspace.plan ?? initialWorkspaceInfo.plan, - status: workspace.status ?? initialWorkspaceInfo.status, - created_at: workspace.created_at ?? initialWorkspaceInfo.created_at, + name: workspace.name, + plan: workspace.plan, + credits: workspace.credits, role: resolveWorkspaceRole(workspace.role), - providers: initialWorkspaceInfo.providers, - trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits, - trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used, - trial_credits_exhausted_at: - workspace.trial_credits_exhausted_at ?? initialWorkspaceInfo.trial_credits_exhausted_at, - next_credit_reset_date: - workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date, - trial_end_reason: workspace.trial_end_reason ?? undefined, - custom_config: workspace.custom_config - ? { - remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined, - replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined, - } - : undefined, } } -export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags { +export function getWorkspaceRoleFlags( + currentWorkspace: GetWorkspacesCurrentSummaryResponse, +): WorkspaceRoleFlags { return { isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role), isCurrentWorkspaceOwner: currentWorkspace.role === 'owner', diff --git a/web/context/workspace-state.ts b/web/context/workspace-state.ts index a73b235a805..5bc5432ae41 100644 --- a/web/context/workspace-state.ts +++ b/web/context/workspace-state.ts @@ -1,19 +1,19 @@ 'use client' import { atom } from 'jotai' -import { atomWithQuery, queryClientAtom } from 'jotai-tanstack-query' +import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' -import { initialWorkspaceInfo } from './app-context-defaults' -import { getWorkspaceRoleFlags, normalizeCurrentWorkspace } from './app-context-normalizers' +import { initialWorkspaceSummary } from './app-context-defaults' +import { getWorkspaceRoleFlags, normalizeCurrentWorkspaceSummary } from './app-context-normalizers' const currentWorkspaceQueryAtom = atomWithQuery(() => { - return consoleQuery.workspaces.current.post.queryOptions({ - select: normalizeCurrentWorkspace, + return consoleQuery.workspaces.current.summary.get.queryOptions({ + select: normalizeCurrentWorkspaceSummary, }) }) export const currentWorkspaceAtom = atom((get) => { - return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo + return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceSummary }) export const currentWorkspaceIdAtom = atom((get) => { @@ -43,8 +43,3 @@ export const isCurrentWorkspaceDatasetOperatorAtom = atom((get) => { export const currentWorkspaceLoadingAtom = atom((get) => { return get(currentWorkspaceQueryAtom).isPending }) - -export const refreshCurrentWorkspaceAtom = atom(null, (get) => { - const queryClient = get(queryClientAtom) - queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() }) -}) diff --git a/web/models/common.ts b/web/models/common.ts index 8a8e9f5b7a4..692cc7c9b0a 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -26,61 +26,6 @@ export type Member = Pick & { - role: 'owner' | 'admin' | 'editor' | 'dataset_operator' | 'normal' - providers: Provider[] - trial_credits: number - trial_credits_used: number - trial_credits_exhausted_at: number - next_credit_reset_date: number - trial_end_reason?: string - custom_config?: { - remove_webapp_brand?: boolean - replace_webapp_logo?: string - } -} - export type DataSourceNotionPage = { page_icon: null | { type: string | null diff --git a/web/service/common.ts b/web/service/common.ts index b14bbed4340..1e8948f3799 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -1,3 +1,7 @@ +import type { + PostWorkspacesInfoData, + PostWorkspacesInfoResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' import type { DefaultModelResponse, Model, @@ -7,7 +11,6 @@ import type { } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CommonResponse, - ICurrentWorkspace, InitValidateStatusResponse, SetupStatusResponse, } from '@/models/common' @@ -105,24 +108,14 @@ export const ownershipTransfer = ( export const fetchFilePreview = ({ fileID }: { fileID: string }): Promise<{ content: string }> => { return get<{ content: string }>(`/files/${fileID}/preview`) } -export const updateCurrentWorkspace = ({ - url, - body, -}: { - url: string - body: Record -}): Promise => { - return post(url, { body }) -} - export const updateWorkspaceInfo = ({ url, body, }: { - url: string - body: Record -}): Promise => { - return post(url, { body }) + url: PostWorkspacesInfoData['url'] + body: PostWorkspacesInfoData['body'] +}): Promise => { + return post(url, { body }) } type InvitationCheckData = { diff --git a/web/service/use-workspace.ts b/web/service/use-workspace.ts index c2c16d39ae8..996a7a6f3a9 100644 --- a/web/service/use-workspace.ts +++ b/web/service/use-workspace.ts @@ -1,17 +1,14 @@ -import type { ICurrentWorkspace } from '@/models/common' +import type { GetWorkspacesCurrentPermissionResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { useQuery } from '@tanstack/react-query' import { get } from './base' -type WorkspacePermissions = { - workspace_id: ICurrentWorkspace['id'] - allow_member_invite: boolean - allow_owner_transfer: boolean -} - -export function useWorkspacePermissions(workspaceId: ICurrentWorkspace['id'], enabled: boolean) { +export function useWorkspacePermissions( + workspaceId: GetWorkspacesCurrentPermissionResponse['workspace_id'], + enabled: boolean, +) { return useQuery({ queryKey: ['workspace-permissions', workspaceId], - queryFn: () => get('/workspaces/current/permission'), + queryFn: () => get('/workspaces/current/permission'), enabled: enabled && !!workspaceId, }) } diff --git a/web/test/console/current-workspace.ts b/web/test/console/current-workspace.ts index bbbd1843ce3..bcfdd56ec0d 100644 --- a/web/test/console/current-workspace.ts +++ b/web/test/console/current-workspace.ts @@ -1,30 +1,25 @@ -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { QueryClient, QueryKey } from '@tanstack/react-query' export const currentWorkspaceQueryKey = [ - ['console', 'workspaces', 'current', 'post'], + ['console', 'workspaces', 'current', 'summary', 'get'], { type: 'query' }, ] as const const createCurrentWorkspaceFixture = ( - overrides: Partial = {}, -): PostWorkspacesCurrentResponse => ({ + overrides: Partial = {}, +): GetWorkspacesCurrentSummaryResponse => ({ id: 'workspace-1', name: 'Workspace', - plan: '', + plan: null, role: 'owner', - status: '', - created_at: 0, - trial_credits: 0, - trial_credits_used: 0, - trial_credits_exhausted_at: 0, - next_credit_reset_date: 0, + credits: null, ...overrides, }) export const seedCurrentWorkspaceQuery = ( queryClient: QueryClient, - workspace: Partial = {}, + workspace: Partial = {}, queryKey: QueryKey = currentWorkspaceQueryKey, ) => { const data = createCurrentWorkspaceFixture(workspace) @@ -34,10 +29,10 @@ export const seedCurrentWorkspaceQuery = ( export const ensureCurrentWorkspaceQuery = ( queryClient: QueryClient, - workspace: Partial = {}, + workspace: Partial = {}, queryKey: QueryKey = currentWorkspaceQueryKey, ) => { - const existingWorkspace = queryClient.getQueryData(queryKey) + const existingWorkspace = queryClient.getQueryData(queryKey) if (existingWorkspace === undefined) return seedCurrentWorkspaceQuery(queryClient, workspace, queryKey) diff --git a/web/test/console/query-data.ts b/web/test/console/query-data.ts index 1b6a6aadf08..0fc0efc26d3 100644 --- a/web/test/console/query-data.ts +++ b/web/test/console/query-data.ts @@ -3,7 +3,7 @@ import type { GetSystemFeaturesLicenseResponse, GetSystemFeaturesResponse, } from '@dify/contracts/api/console/system-features/types.gen' -import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { QueryClient } from '@tanstack/react-query' import type { RenderHookOptions, @@ -45,7 +45,9 @@ type AppDslVersionQueryProvider = { } type CurrentWorkspaceQueryProvider = { - post?: QueryKeyProvider + summary?: { + get?: QueryKeyProvider + } } const fallbackTrialModelsQueryKey = ['console', 'trialModels', 'get'] as const @@ -71,7 +73,7 @@ const getCurrentWorkspaceQueryKey = () => { } ).workspaces?.current - return currentWorkspaceQuery?.post?.queryKey() ?? currentWorkspaceQueryKey + return currentWorkspaceQuery?.summary?.get?.queryKey() ?? currentWorkspaceQueryKey } /** @@ -140,7 +142,7 @@ export type ConsoleQueryTestOptions = { */ systemFeatures?: DeepPartial | null accountProfile?: Partial | null - currentWorkspace?: Partial | null + currentWorkspace?: Partial | null trialModels?: readonly string[] | null workspacePermissionKeys?: readonly string[] | null /** diff --git a/web/test/console/state-fixture.ts b/web/test/console/state-fixture.ts index 9de2d5fec37..f62f66f59e0 100644 --- a/web/test/console/state-fixture.ts +++ b/web/test/console/state-fixture.ts @@ -1,6 +1,6 @@ +import type { GetWorkspacesCurrentSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { createStore } from 'jotai' import type { LangGeniusVersionInfo } from '@/context/app-context-types' -import type { ICurrentWorkspace } from '@/models/common' import { atom } from 'jotai' import { createSystemFeaturesFixture } from '@/test/console/system-features' @@ -13,12 +13,7 @@ export type ConsoleStateFixture = { avatar_url?: string | null is_password_set?: boolean } | null - currentWorkspace?: - | ({ - id?: string - name?: string - } & Partial) - | null + currentWorkspace?: Partial | null isCurrentWorkspaceManager?: boolean isCurrentWorkspaceOwner?: boolean isCurrentWorkspaceEditor?: boolean @@ -51,16 +46,10 @@ const defaultUserProfile = { const defaultCurrentWorkspace = { id: 'workspace-1', name: 'Workspace', - plan: '', - status: '', - created_at: 0, + plan: null, + credits: null, role: 'owner', - providers: [], - trial_credits: 0, - trial_credits_used: 0, - trial_credits_exhausted_at: 0, - next_credit_reset_date: 0, -} satisfies ICurrentWorkspace +} satisfies GetWorkspacesCurrentSummaryResponse const defaultLangGeniusVersionInfo = { current_env: 'CLOUD', @@ -83,7 +72,7 @@ const accountProfileMetaAtom = atom({ currentVersion: null, currentEnv: null }) const refreshUserProfileCallbackAtom = atom({ callback: () => {} }) const refreshUserProfileAtom = atom(null, (get) => get(refreshUserProfileCallbackAtom).callback()) -const currentWorkspaceAtom = atom(defaultCurrentWorkspace) +const currentWorkspaceAtom = atom(defaultCurrentWorkspace) const currentWorkspaceIdAtom = atom((get) => get(currentWorkspaceAtom).id) const isCurrentWorkspaceManagerAtom = atom(false) const isCurrentWorkspaceOwnerAtom = atom(false)