From 66a545fc6d11c6195c42917d78894cd1558161c2 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:15:02 +0800 Subject: [PATCH] refactor: centralize deployment edition in system features (#39454) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: GareArc --- api/commands/system.py | 3 +- api/configs/app_config.py | 9 + api/configs/feature/__init__.py | 5 + api/controllers/console/auth/login.py | 9 +- api/controllers/console/feature.py | 32 +- api/controllers/console/init_validate.py | 3 +- api/controllers/console/setup.py | 5 +- api/controllers/console/workspace/account.py | 3 +- api/controllers/console/workspace/members.py | 2 +- .../console/workspace/workspace.py | 3 +- api/controllers/console/wraps.py | 7 +- api/controllers/openapi/auth/data.py | 3 +- api/controllers/web/site.py | 5 +- api/core/hosting_configuration.py | 3 +- api/core/provider_manager.py | 5 +- api/enums/deployment_edition.py | 11 + api/openapi/markdown/console-openapi.md | 33 +- api/openapi/markdown/web-openapi.md | 38 +- api/services/account_service.py | 10 +- api/services/feature_service.py | 73 +++- api/services/workspace_service.py | 3 +- api/tasks/workflow_cfs_scheduler/entities.py | 3 +- .../services/test_account_service.py | 29 +- .../services/test_feature_service.py | 136 ++++-- .../services/test_workspace_service.py | 17 +- .../console/app/test_app_import_api.py | 6 +- .../console/auth/test_email_register.py | 31 +- .../console/auth/test_email_verification.py | 6 +- .../console/auth/test_forgot_password.py | 22 +- .../console/auth/test_login_logout.py | 4 +- .../console/auth/test_password_reset.py | 6 +- .../controllers/console/test_feature.py | 69 ++- .../console/workspace/test_members.py | 64 +-- .../controllers/openapi/auth/test_data.py | 7 +- .../web/test_web_forgot_password.py | 6 +- .../controllers/web/test_web_login.py | 3 +- .../test_plugin_service_installation.py | 4 +- .../services/test_account_service.py | 16 +- .../test_feature_service_change_email.py | 15 + ...test_feature_service_deployment_edition.py | 35 ++ .../test_feature_service_enable_app_deploy.py | 3 +- .../test_feature_service_knowledge_fs.py | 3 +- .../test_feature_service_learn_app.py | 7 +- .../test_feature_service_licensed_seats.py | 36 +- ...st_feature_service_webapp_public_access.py | 3 +- .../services/test_recommended_app_service.py | 44 +- docker/envs/core-services/shared.env.example | 16 +- docker/envs/core-services/web.env.example | 1 - lint.config.ts | 2 +- oxlint-suppressions.json | 5 - .../api/console/system-features/orpc.gen.ts | 36 +- .../api/console/system-features/types.gen.ts | 31 +- .../api/console/system-features/zod.gen.ts | 35 +- .../contracts/generated/api/web/types.gen.ts | 22 +- .../contracts/generated/api/web/zod.gen.ts | 51 +-- web/.env.example | 21 - web/Dockerfile | 1 - .../billing/billing-integration.test.tsx | 20 +- .../education-verification-flow.test.tsx | 20 +- .../billing/partner-stack-flow.test.tsx | 16 +- web/__tests__/env.spec.ts | 28 -- .../__tests__/hydration-boundary.spec.tsx | 49 ++- ...ec.tsx => profile-bootstrap-gate.spec.tsx} | 31 +- .../overview/__tests__/chart-view.spec.tsx | 7 +- .../[appId]/overview/chart-view.tsx | 34 +- .../(commonLayout)/console-bootstrap-gate.tsx | 14 - .../__tests__/layout-main.spec.tsx | 8 - .../[datasetId]/layout-main.tsx | 8 +- .../new/[knowledgeSpaceId]/layout.tsx | 7 +- .../datasets/new/__tests__/layout.spec.tsx | 71 ++++ .../datasets/new/create/page.tsx | 7 +- .../(commonLayout)/datasets/new/layout.tsx | 14 + .../deployments/__tests__/layout.spec.tsx | 10 +- web/app/(commonLayout)/deployments/layout.tsx | 4 +- web/app/(commonLayout)/error.tsx | 11 +- web/app/(commonLayout)/hydration-boundary.tsx | 6 +- .../(commonLayout)/profile-bootstrap-gate.tsx | 11 + web/app/(commonLayout)/providers.tsx | 6 +- .../webapp-signin/normalForm.tsx | 6 +- web/app/__tests__/error.spec.tsx | 39 ++ web/app/__tests__/layout.spec.tsx | 71 ++++ .../(commonLayout)/account-page/index.tsx | 4 +- web/app/auth/refresh/__tests__/route.spec.ts | 12 +- .../external-attribution-recorder.spec.tsx | 18 - .../__tests__/index.spec.tsx | 10 +- .../app-publisher/__tests__/sections.spec.tsx | 3 +- .../config-var/__tests__/index.spec.tsx | 3 +- .../config-modal/__tests__/index.spec.tsx | 3 +- .../settings-modal/__tests__/index.spec.tsx | 4 +- .../app-card/__tests__/index.spec.tsx | 13 +- .../app/create-app-dialog/app-card/index.tsx | 9 +- .../__tests__/notification.spec.tsx | 34 +- .../app/in-site-message/notification.tsx | 13 +- .../__tests__/archived-logs-notice.spec.tsx | 19 +- .../app/log/archived-logs-notice.tsx | 9 +- .../overview/__tests__/app-card-utils.spec.ts | 1 + .../__tests__/cloud.spec.tsx | 4 +- .../__tests__/index.spec.tsx | 4 +- .../__tests__/test-utils.tsx | 14 +- .../app/overview/apikey-info-panel/index.tsx | 9 +- .../components/app/overview/app-card-utils.ts | 14 +- .../components/apps/__tests__/list.spec.tsx | 4 + .../__tests__/AmplitudeProvider.spec.tsx | 8 - .../base/amplitude/__tests__/init.spec.ts | 8 - .../base/amplitude/__tests__/utils.spec.ts | 38 +- web/app/components/base/amplitude/init.ts | 6 +- web/app/components/base/amplitude/utils.ts | 4 +- .../base/analytics-consent/README.md | 7 +- .../cloud-analytics-runtime.spec.tsx | 5 + ...dary.spec.tsx => cloud-analytics.spec.tsx} | 102 +++-- .../__tests__/request-boundary.spec.ts | 6 +- .../cloud-analytics-boundary.tsx | 27 -- .../cloud-analytics-runtime.tsx | 2 + .../cloud-analytics-state.ts | 35 -- .../analytics-consent/cloud-analytics.tsx | 50 +++ .../analytics-consent/request-boundary.ts | 14 +- .../moderation-setting-modal.spec.tsx | 5 +- .../base/zendesk/__tests__/index.spec.tsx | 57 ++- .../base/zendesk/__tests__/utils.spec.ts | 68 ++- web/app/components/base/zendesk/index.tsx | 12 +- web/app/components/base/zendesk/utils.ts | 23 +- .../__tests__/cookie-recorder.spec.tsx | 33 ++ .../billing/partner-stack/cookie-recorder.tsx | 21 +- .../billing/partner-stack/index.tsx | 14 +- web/app/components/billing/plan/index.tsx | 28 +- .../components/billing/upgrade-btn/index.tsx | 9 +- .../custom-page/__tests__/index.spec.tsx | 2 +- .../components/custom/custom-page/index.tsx | 10 +- .../access-config/__tests__/index.spec.tsx | 11 - .../datasets/access-config/index.tsx | 8 +- .../file-uploader/__tests__/index.spec.tsx | 13 +- .../hooks/__tests__/use-file-upload.spec.tsx | 16 +- .../file-uploader/hooks/use-file-upload.ts | 12 +- .../create/step-one/__tests__/index.spec.tsx | 10 +- .../step-one/__tests__/upgrade-card.spec.tsx | 9 +- .../__tests__/preview-panel.spec.tsx | 3 +- .../datasets/create/step-one/upgrade-card.tsx | 9 +- .../create/step-two/__tests__/index.spec.tsx | 9 +- .../general-chunking-options.spec.tsx | 9 +- .../__tests__/indexing-mode-section.spec.tsx | 3 +- .../__tests__/parent-child-options.spec.tsx | 9 +- .../components/general-chunking-options.tsx | 12 +- .../components/parent-child-options.tsx | 10 +- .../__tests__/documents-header.spec.tsx | 3 +- .../components/__tests__/operations.spec.tsx | 3 +- .../document-list/__tests__/index.spec.tsx | 24 +- .../__tests__/document-table-row.spec.tsx | 5 +- .../documents/components/operations.tsx | 10 +- .../__tests__/use-local-file-upload.spec.tsx | 16 +- .../__tests__/index.spec.tsx | 3 +- .../steps/__tests__/step-one-content.spec.tsx | 12 +- .../common/__tests__/batch-action.spec.tsx | 3 +- .../detail/completed/common/batch-action.tsx | 10 +- .../segment-add/__tests__/index.spec.tsx | 3 +- .../__tests__/index.spec.tsx | 3 +- .../__tests__/modify-retrieval-modal.spec.tsx | 3 +- .../datasets/list/__tests__/index.spec.tsx | 89 +++- .../__tests__/operations-dropdown.spec.tsx | 11 - .../components/operations-dropdown.tsx | 8 +- web/app/components/datasets/list/index.tsx | 8 +- .../settings/form/__tests__/index.spec.tsx | 34 +- .../__tests__/basic-info-section.spec.tsx | 31 +- .../__tests__/indexing-section.spec.tsx | 3 +- .../form/components/indexing-section.tsx | 10 +- .../__tests__/index.spec.tsx | 17 +- .../settings/permission-selector/index.tsx | 8 +- .../explore/__tests__/index.spec.tsx | 3 +- .../explore/app-card/__tests__/index.spec.tsx | 30 +- web/app/components/explore/app-card/index.tsx | 11 +- .../explore/app-list/__tests__/index.spec.tsx | 44 +- .../learn-dify/__tests__/item.spec.tsx | 27 +- .../components/explore/learn-dify/item.tsx | 11 +- .../explore/sidebar/__tests__/index.spec.tsx | 3 +- .../explore/try-app/__tests__/index.spec.tsx | 11 +- web/app/components/explore/try-app/index.tsx | 7 +- .../external-attribution-recorder.tsx | 3 +- .../actions/commands/__tests__/slash.spec.tsx | 17 +- .../account-about/__tests__/index.spec.tsx | 27 +- .../components/header/account-about/index.tsx | 11 +- .../account-dropdown/__tests__/index.spec.tsx | 29 +- .../account-dropdown/default-menu-content.tsx | 5 +- .../account-setting/__tests__/index.spec.tsx | 28 +- .../__tests__/index.spec.tsx | 33 +- .../__tests__/selector.spec.tsx | 7 + .../header/account-setting/index.tsx | 4 +- .../invited-modal/__tests__/index.spec.tsx | 31 +- .../members-page/invited-modal/index.tsx | 21 +- .../__tests__/index.non-cloud.spec.tsx | 1 - .../__tests__/supports-credits.spec.ts | 25 +- .../model-provider-page-body.tsx | 9 +- .../model-selector/__tests__/popup.spec.tsx | 6 +- .../model-selector/popup.tsx | 17 +- .../__tests__/credential-panel.spec.tsx | 6 +- .../__tests__/index.spec.tsx | 28 +- .../model-load-balancing-configs.spec.tsx | 9 +- .../use-credential-panel-state.spec.ts | 6 +- .../provider-added-card/index.tsx | 10 +- .../__tests__/dropdown-content.spec.tsx | 2 +- .../credits-exhausted-alert.tsx | 26 +- .../model-load-balancing-configs.tsx | 9 +- .../provider-added-card/quota-panel.tsx | 10 +- .../use-credential-panel-state.ts | 12 +- .../model-provider-page/supports-credits.ts | 5 +- .../__tests__/index.spec.tsx | 2 +- .../workflow-log-archives-page/index.tsx | 12 +- .../license-env/__tests__/index.spec.tsx | 64 ++- .../components/header/license-env/index.tsx | 22 +- .../plan-badge/__tests__/index.spec.tsx | 22 +- .../components/header/plan-badge/index.tsx | 9 +- .../integrations/__tests__/page.spec.tsx | 22 +- .../main-nav/__tests__/index.spec.tsx | 2 +- .../__tests__/support-menu.spec.tsx | 19 +- .../__tests__/workspace-card.spec.tsx | 61 ++- .../main-nav/components/help-menu.tsx | 7 +- .../main-nav/components/support-menu.tsx | 17 +- .../main-nav/components/workspace-card.tsx | 18 +- .../use-install-plugin-limit.spec.ts | 15 - .../hooks/use-install-plugin-limit.tsx | 21 +- .../steps/install.tsx | 6 +- .../__tests__/hydration-server.spec.tsx | 37 +- .../plugins/marketplace/hydration-server.tsx | 4 +- .../components/plugins/marketplace/index.tsx | 6 +- .../__tests__/endpoint-list.spec.tsx | 3 +- .../publisher/__tests__/index.spec.tsx | 9 +- .../publisher/__tests__/popup.spec.tsx | 14 +- .../rag-pipeline-header/publisher/popup.tsx | 12 +- .../hooks/__tests__/index.spec.ts | 2 +- .../text-generation-sidebar.spec.tsx | 10 +- .../__tests__/mount.spec.tsx | 27 +- .../step-by-step-tour/__tests__/state.spec.ts | 9 +- .../components/step-by-step-tour/mount.tsx | 11 +- web/app/components/step-by-step-tour/state.ts | 8 +- .../tools/mcp/__tests__/create-card.spec.tsx | 14 +- .../mcp/__tests__/mcp-service-card.spec.tsx | 16 +- .../tools/mcp/__tests__/modal.spec.tsx | 27 +- .../tools/provider/__tests__/empty.spec.tsx | 3 +- .../__tests__/use-workflow-comment.spec.ts | 4 + .../agent-strategy-selector.spec.tsx | 4 - .../agent-orchestrate-panel-content.spec.tsx | 392 +++++++++--------- .../agent-orchestrate-panel-content.tsx | 7 +- .../delivery-method/__tests__/index.spec.tsx | 3 +- .../__tests__/method-selector.spec.tsx | 10 +- .../__tests__/upgrade-modal.spec.tsx | 12 +- .../delivery-method/method-selector.tsx | 9 +- .../delivery-method/upgrade-modal.tsx | 9 +- .../knowledge-base/__tests__/panel.spec.tsx | 23 +- .../workflow/nodes/knowledge-base/panel.tsx | 11 +- .../__tests__/index.spec.tsx | 2 +- .../action-menu/__tests__/index.spec.tsx | 12 +- .../workflow/run/__tests__/node.spec.tsx | 3 +- .../__tests__/iteration-result-panel.spec.tsx | 3 +- .../__tests__/retry-result-panel.spec.tsx | 3 +- .../__tests__/index.spec.tsx | 4 - .../device/__tests__/page-terminal.spec.tsx | 7 +- web/app/device/page.tsx | 10 +- .../__tests__/education-apply-page.spec.tsx | 18 +- .../education-apply/expire-notice-modal.tsx | 9 +- web/app/error.tsx | 11 +- web/app/layout.tsx | 54 +-- web/app/signin/__tests__/_header.spec.tsx | 20 +- web/app/signin/__tests__/normal-form.spec.tsx | 62 +-- .../signin/check-code/__tests__/page.spec.tsx | 1 - web/app/signin/normal-form.tsx | 6 +- web/config/__tests__/index.spec.ts | 59 --- web/config/index.ts | 10 +- .../__tests__/console-bootstrap.spec.tsx | 89 ++-- .../hooks/use-trigger-events-limit-modal.ts | 11 +- web/context/i18n.spec.ts | 29 +- web/context/i18n.ts | 18 +- web/context/modal-context.test.tsx | 26 +- web/context/provider-context-provider.tsx | 20 +- web/context/query-client.tsx | 13 +- web/context/system-features-state.ts | 10 +- web/context/version-state.ts | 6 +- web/context/web-app-context.tsx | 6 +- web/context/zendesk-conversation-sync.ts | 26 +- web/docker/entrypoint.sh | 19 - web/env.ts | 90 ---- .../__tests__/access-surface-cards.spec.tsx | 4 + .../configure/__tests__/page.spec.tsx | 51 +-- .../__tests__/community-edition-tip.spec.tsx | 35 +- .../components/community-edition-tip.tsx | 10 +- .../files/__tests__/index.spec.tsx | 22 +- .../knowledge/__tests__/index.spec.tsx | 19 +- .../preview/__tests__/chat.spec.tsx | 20 +- .../preview/__tests__/header.spec.tsx | 3 +- .../components/preview/chat-session.tsx | 7 +- .../agent-v2/agent-detail/configure/page.tsx | 12 +- .../agent-detail/logs/__tests__/page.spec.tsx | 22 +- .../__tests__/permissions.spec.tsx | 4 + .../__tests__/knowledge-route-guard.spec.tsx | 60 --- .../new-rag/knowledge-route-guard.tsx | 20 - web/features/system-features/README.md | 13 + .../__tests__/bootstrap-boundary.spec.tsx | 120 ++++++ .../__tests__/system-features.spec.ts | 287 ------------- .../system-features/bootstrap-boundary.tsx | 31 ++ web/features/system-features/client.ts | 45 +- web/features/system-features/config.ts | 118 ------ web/features/system-features/server.ts | 35 -- web/hooks/use-document-title.ts | 26 +- web/service/__tests__/base.spec.ts | 1 - web/service/__tests__/server.spec.ts | 4 +- .../__tests__/sse-generator-post.spec.ts | 1 - .../__tests__/use-app-access-control.spec.tsx | 64 ++- .../access-control/use-app-access-control.ts | 9 +- web/service/base.ts | 9 +- web/service/use-explore.ts | 9 +- web/test/console/query-data.ts | 73 +--- web/test/console/state-fixture.ts | 33 +- web/test/console/system-features.ts | 124 ++++++ 310 files changed, 3277 insertions(+), 3047 deletions(-) create mode 100644 api/enums/deployment_edition.py create mode 100644 api/tests/unit_tests/services/test_feature_service_change_email.py create mode 100644 api/tests/unit_tests/services/test_feature_service_deployment_edition.py rename web/app/(commonLayout)/__tests__/{console-bootstrap-gate.spec.tsx => profile-bootstrap-gate.spec.tsx} (68%) delete mode 100644 web/app/(commonLayout)/console-bootstrap-gate.tsx create mode 100644 web/app/(commonLayout)/datasets/new/__tests__/layout.spec.tsx create mode 100644 web/app/(commonLayout)/datasets/new/layout.tsx create mode 100644 web/app/(commonLayout)/profile-bootstrap-gate.tsx create mode 100644 web/app/__tests__/error.spec.tsx create mode 100644 web/app/__tests__/layout.spec.tsx rename web/app/components/base/analytics-consent/__tests__/{cloud-analytics-boundary.spec.tsx => cloud-analytics.spec.tsx} (52%) delete mode 100644 web/app/components/base/analytics-consent/cloud-analytics-boundary.tsx delete mode 100644 web/app/components/base/analytics-consent/cloud-analytics-state.ts create mode 100644 web/app/components/base/analytics-consent/cloud-analytics.tsx create mode 100644 web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx delete mode 100644 web/config/__tests__/index.spec.ts delete mode 100644 web/features/new-rag/__tests__/knowledge-route-guard.spec.tsx delete mode 100644 web/features/new-rag/knowledge-route-guard.tsx create mode 100644 web/features/system-features/README.md create mode 100644 web/features/system-features/__tests__/bootstrap-boundary.spec.tsx delete mode 100644 web/features/system-features/__tests__/system-features.spec.ts create mode 100644 web/features/system-features/bootstrap-boundary.tsx delete mode 100644 web/features/system-features/config.ts delete mode 100644 web/features/system-features/server.ts create mode 100644 web/test/console/system-features.ts diff --git a/api/commands/system.py b/api/commands/system.py index c1f91b43869..699e0c199c9 100644 --- a/api/commands/system.py +++ b/api/commands/system.py @@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.orm import sessionmaker from configs import dify_config +from enums.deployment_edition import DeploymentEdition from events.app_event import app_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -42,7 +43,7 @@ def reset_encrypt_key_pair(): After the reset, all LLM credentials will become invalid, requiring re-entry. Only support SELF_HOSTED mode. """ - if dify_config.EDITION != "SELF_HOSTED": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red")) return with sessionmaker(db.engine, expire_on_commit=False).begin() as session: diff --git a/api/configs/app_config.py b/api/configs/app_config.py index 29828a3be0d..be2f3c7c0e5 100644 --- a/api/configs/app_config.py +++ b/api/configs/app_config.py @@ -5,6 +5,7 @@ from typing import Any, override from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource +from enums.deployment_edition import DeploymentEdition from libs.file_utils import search_file_upwards from .deploy import DeploymentConfig @@ -116,3 +117,11 @@ class DifyConfig( ), ), ) + + @property + def DEPLOYMENT_EDITION(self) -> DeploymentEdition: + if self.EDITION == "CLOUD": + return DeploymentEdition.CLOUD + if self.ENTERPRISE_ENABLED: + return DeploymentEdition.ENTERPRISE + return DeploymentEdition.COMMUNITY diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 5897698d1c8..c28716e3b0e 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1497,6 +1497,11 @@ class LoginConfig(BaseSettings): class AccountConfig(BaseSettings): + ENABLE_CHANGE_EMAIL: bool = Field( + description="whether users can change their email address", + default=True, + ) + ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( description="Duration in minutes for which a account deletion token remains valid", default=5, diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index e022c9dd28b..637f9ca0825 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -162,9 +162,10 @@ class LoginApi(Resource): # SELF_HOSTED only have one workspace tenants = TenantService.get_join_tenants(account, session=db.session()) if len(tenants) == 0: - system_features = FeatureService.get_system_features() - - if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available(): + if ( + FeatureService.get_system_features().is_allow_create_workspace + and not FeatureService.get_license().workspaces.is_available() + ): raise WorkspacesLimitExceeded() else: return SimpleResultOptionalDataResponse( @@ -310,7 +311,7 @@ class EmailCodeLoginApi(Resource): if account: tenants = TenantService.get_join_tenants(account, session=db.session()) if not tenants: - workspaces = FeatureService.get_system_features().license.workspaces + workspaces = FeatureService.get_license().workspaces if not workspaces.is_available(): raise WorkspacesLimitExceeded() if not FeatureService.get_system_features().is_allow_create_workspace: diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index 3b1b414150a..587871e7a97 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -3,10 +3,11 @@ from flask_restx import Resource from controllers.common.schema import register_response_schema_models from fields.base import ResponseModel from libs.helper import dump_response -from libs.login import current_account_with_tenant_optional, login_required +from libs.login import login_required from services.feature_service import ( FeatureModel, FeatureService, + LicenseModel, LimitationModel, SystemFeatureModel, ) @@ -32,6 +33,7 @@ register_response_schema_models( console_ns, AppDslVersionResponse, FeatureModel, + LicenseModel, LimitationModel, SystemFeatureModel, TrialModelsResponse, @@ -135,8 +137,28 @@ class SystemFeatureApi(Resource): Authentication would create circular dependency (can't login without dashboard loading). - Only non-sensitive configuration data should be returned by this endpoint. + Only non-sensitive configuration data should be returned by this endpoint. Authenticated + license detail is served separately by SystemFeatureLicenseApi. """ - current_user, _ = current_account_with_tenant_optional() - is_authenticated = current_user is not None - return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump() + return FeatureService.get_system_features().model_dump() + + +@console_ns.route("/system-features/license") +class SystemFeatureLicenseApi(Resource): + @console_ns.doc("get_system_license") + @console_ns.doc(description="Get license status and usage detail") + @console_ns.response( + 200, + "Success", + console_ns.models[LicenseModel.__name__], + ) + @setup_required + @login_required + @account_initialization_required + def get(self): + """Get full license detail (status, expiry, workspace/seat usage). + + Authenticated counterpart to the license *status* exposed on the public + system-features endpoint. + """ + return FeatureService.get_license().model_dump() diff --git a/api/controllers/console/init_validate.py b/api/controllers/console/init_validate.py index f155f222e19..aca22c67f8e 100644 --- a/api/controllers/console/init_validate.py +++ b/api/controllers/console/init_validate.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from configs import dify_config from controllers.fastopenapi import console_router +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from models.model import DifySetup from services.account_service import TenantService @@ -63,7 +64,7 @@ def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse def get_init_validate_status() -> bool: - if dify_config.EDITION == "SELF_HOSTED": + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: if os.environ.get("INIT_PASSWORD"): if session.get("is_init_validated"): return True diff --git a/api/controllers/console/setup.py b/api/controllers/console/setup.py index e0a0fba3329..825cfb02c3e 100644 --- a/api/controllers/console/setup.py +++ b/api/controllers/console/setup.py @@ -6,6 +6,7 @@ from sqlalchemy import select from configs import dify_config from controllers.fastopenapi import console_router +from enums.deployment_edition import DeploymentEdition from libs.helper import EmailStr, extract_remote_ip from libs.password import valid_password from models.model import DifySetup, db @@ -52,7 +53,7 @@ def get_setup_status_api() -> SetupStatusResponse: Only bootstrap-safe status information should be returned by this endpoint. """ - if dify_config.EDITION == "SELF_HOSTED": + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: setup_status = get_setup_status() if setup_status and not isinstance(setup_status, bool): return SetupStatusResponse(step="finished", setup_at=setup_status.setup_at.isoformat()) @@ -102,7 +103,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse: def get_setup_status() -> DifySetup | bool | None: - if dify_config.EDITION == "SELF_HOSTED": + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return db.session.scalar(select(DifySetup).limit(1)) return True diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 6a06ed2d3e6..a37476953c4 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -46,6 +46,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from fields.base import ResponseModel from fields.member_fields import AccountResponse @@ -262,7 +263,7 @@ class AccountInitApi(Resource): payload = console_ns.payload or {} args = AccountInitPayload.model_validate(payload) - if dify_config.EDITION == "CLOUD": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: if not args.invitation_code: raise ValueError("invitation_code is required") diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 3e70de05347..9d40d32aaa2 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -198,7 +198,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou if workspace_members.enabled is True and not workspace_members.is_available(new_member_count): raise WorkspaceMembersLimitExceeded() if new_account_count > 0: - seats = FeatureService.get_system_features(is_authenticated=True).license.seats + seats = FeatureService.get_license().seats if not seats.is_available(new_account_count): raise SeatsLimitExceeded() return diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 6d67960f622..dbb9047e0ec 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -37,6 +37,7 @@ from controllers.console.wraps import ( with_current_user, ) from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import dump_response, to_timestamp @@ -233,7 +234,7 @@ class TenantListApi(Resource): tenants = [tenant for tenant, _ in tenant_rows] tenant_dicts = [] is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED - is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED + is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED tenant_plans: dict[str, SubscriptionPlan] = {} if is_saas: diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 4e22d334bec..b77c78761a0 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -20,6 +20,7 @@ from controllers.common.wraps import ( from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.encryption import FieldEncryption @@ -129,7 +130,7 @@ def account_initialization_required[R](view: Callable[..., R]) -> Callable[..., def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - if dify_config.EDITION != "CLOUD": + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: abort(404) return view(*args, **kwargs) @@ -151,7 +152,7 @@ def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]: def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - if dify_config.EDITION != "SELF_HOSTED": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: abort(404) return view(*args, **kwargs) @@ -327,7 +328,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]: # The overloads keep Resource methods method-aware for pyrefly while # preserving support for plain functions used in tests and utilities. # check setup - if dify_config.EDITION == "SELF_HOSTED" and not _is_setup_completed(): + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and not _is_setup_completed(): if os.environ.get("INIT_PASSWORD"): raise NotInitValidateError() raise NotSetupError() diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index fa68b48d975..898a9eb8f87 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -8,6 +8,7 @@ from werkzeug.exceptions import InternalServerError from configs import dify_config from core.rbac import RBACPermission, RBACResourceScope +from enums.deployment_edition import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import Account, Tenant, TenantAccountRole from models.model import App, EndUser @@ -26,7 +27,7 @@ class CallerKind(StrEnum): def current_edition() -> Edition: - if dify_config.EDITION == "CLOUD": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: return Edition.SAAS if dify_config.ENTERPRISE_ENABLED: return Edition.EE diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index b5999bc093d..f6c4af013a1 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -8,6 +8,7 @@ from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.web import web_ns from controllers.web.wraps import WebApiResource +from enums.deployment_edition import DeploymentEdition from extensions.ext_database import db from extensions.storage.storage_type import StorageType from fields.base import ResponseModel @@ -127,7 +128,9 @@ def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None: """Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere.""" if site.icon_type != IconType.IMAGE or not site.icon: return None - if dify_config.EDITION == "CLOUD" and StorageType(dify_config.STORAGE_TYPE) == StorageType.S3: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and ( + StorageType(dify_config.STORAGE_TYPE) == StorageType.S3 + ): return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id) return build_icon_url(site.icon_type, site.icon) diff --git a/api/core/hosting_configuration.py b/api/core/hosting_configuration.py index 1a25c23d404..09473b8b78f 100644 --- a/api/core/hosting_configuration.py +++ b/api/core/hosting_configuration.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from configs import dify_config from core.entities import DEFAULT_PLUGIN_ID from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, RestrictModel +from enums.deployment_edition import DeploymentEdition from graphon.model_runtime.entities.model_entities import ModelType @@ -49,7 +50,7 @@ class HostingConfiguration: self.moderation_config = None def init_app(self, app: Flask): - if dify_config.EDITION != "CLOUD": + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return self.provider_map[f"{DEFAULT_PLUGIN_ID}/azure_openai/azure_openai"] = self.init_azure_openai() diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index ebfe77e8f30..0064252b64a 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -34,6 +34,7 @@ from core.entities.provider_entities import ( from core.helper import encrypter from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType from core.helper.position_helper import is_filtered +from enums.deployment_edition import DeploymentEdition from extensions import ext_hosting_provider from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -743,7 +744,7 @@ class ProviderManager: if preferred_provider_type_record: preferred_provider_type = preferred_provider_type_record.preferred_provider_type - elif dify_config.EDITION == "CLOUD" and system_configuration.enabled: + elif dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_configuration.enabled: preferred_provider_type = ProviderType.SYSTEM elif custom_configuration.provider or custom_configuration.models: preferred_provider_type = ProviderType.CUSTOM @@ -1538,7 +1539,7 @@ class ProviderManager: quota_type_to_provider_records_dict[provider_record.quota_type] = provider_record # type: ignore[index] quota_configurations = [] - if dify_config.EDITION == "CLOUD": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: from services.credit_pool_service import CreditPoolService trail_pool = CreditPoolService.get_pool( diff --git a/api/enums/deployment_edition.py b/api/enums/deployment_edition.py new file mode 100644 index 00000000000..5541651b576 --- /dev/null +++ b/api/enums/deployment_edition.py @@ -0,0 +1,11 @@ +from enum import StrEnum + + +class DeploymentEdition(StrEnum): + """ + Enum representing the deployment edition of the platform. + """ + + COMMUNITY = "COMMUNITY" + ENTERPRISE = "ENTERPRISE" + CLOUD = "CLOUD" diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 2643812c6bf..55b983deb5a 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -9483,7 +9483,8 @@ data required for dashboard initialization. Authentication would create circular dependency (can't login without dashboard loading). -Only non-sensitive configuration data should be returned by this endpoint. +Only non-sensitive configuration data should be returned by this endpoint. Authenticated +license detail is served separately by SystemFeatureLicenseApi. #### Responses @@ -9491,6 +9492,19 @@ Only non-sensitive configuration data should be returned by this endpoint. | ---- | ----------- | ------ | | 200 | Success | **application/json**: [SystemFeatureModel](#systemfeaturemodel)
| +### [GET] /system-features/license +**Get full license detail (status, expiry, workspace/seat usage)** + +Get license status and usage detail +Authenticated counterpart to the license *status* exposed on the public +system-features endpoint. + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [LicenseModel](#licensemodel)
| + ### [POST] /tag-bindings #### Request Body @@ -17318,6 +17332,14 @@ Default model entity. | tool_name | string | | Yes | | type | string | | Yes | +#### DeploymentEdition + +Enum representing the deployment edition of the platform. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DeploymentEdition | string | Enum representing the deployment edition of the platform. | | + #### DismissNotificationPayload | Name | Type | Description | Required | @@ -18815,6 +18837,12 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | LicenseStatus | string | | | +#### LicenseStatusModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | [LicenseStatus](#licensestatus) | | Yes | + #### LimitationModel | Name | Type | Description | Required | @@ -22027,6 +22055,7 @@ Model class for provider system configuration response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes | | enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | @@ -22043,7 +22072,7 @@ Model class for provider system configuration response. | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | | knowledge_fs_enabled | boolean | | Yes | -| license | [LicenseModel](#licensemodel) | | Yes | +| license | [LicenseStatusModel](#licensestatusmodel) | | Yes | | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index b826b5fe8e0..954e7c17e8a 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1058,6 +1058,14 @@ Button styles for user actions. | auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No | | name | string | Conversation name. Required when `auto_generate` is `false`. | No | +#### DeploymentEdition + +Enum representing the deployment edition of the platform. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DeploymentEdition | string | Enum representing the deployment edition of the platform. | | + #### EmailCodeLoginSendPayload | Name | Type | Description | Required | @@ -1284,33 +1292,18 @@ Parsed multipart form fields for HITL uploads. | ---- | ---- | ----------- | -------- | | JsonValue | | | | -#### LicenseLimitationModel - -- enabled: whether this limit is enforced -- size: current usage count -- limit: maximum allowed count; 0 means unlimited - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| enabled | boolean | Whether this limit is currently active | Yes | -| limit | integer | Maximum number of resources allowed; 0 means no limit | Yes | -| size | integer | Number of resources already consumed | Yes | - -#### LicenseModel - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| expired_at | string | | Yes | -| seats | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | -| status | [LicenseStatus](#licensestatus) | | Yes | -| workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes | - #### LicenseStatus | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | LicenseStatus | string | | | +#### LicenseStatusModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | [LicenseStatus](#licensestatus) | | Yes | + #### LoginPayload | Name | Type | Description | Required | @@ -1567,6 +1560,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes | | enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | @@ -1583,7 +1577,7 @@ Default configuration for form inputs. | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | | knowledge_fs_enabled | boolean | | Yes | -| license | [LicenseModel](#licensemodel) | | Yes | +| license | [LicenseStatusModel](#licensestatusmodel) | | Yes | | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | diff --git a/api/services/account_service.py b/api/services/account_service.py index 432a2484ed6..5784f19a3a2 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -442,9 +442,9 @@ class AccountService: # A licensed seat is one Account row, deployment-wide; joining an existing # account into another workspace does not pass through here and costs no seat. - # is_authenticated=True: server-side enforcement needs the full license payload, - # which the enterprise fill withholds from unauthenticated (browser-facing) calls. - if not FeatureService.get_system_features(is_authenticated=True).license.seats.is_available(): + # get_license() carries the full license payload that server-side enforcement needs; + # the public system-features endpoint exposes only license status. + if not FeatureService.get_license().seats.is_available(): raise SeatsLimitExceededError("licensed seats limit exceeded") if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): @@ -1332,7 +1332,7 @@ class TenantService: ): raise WorkSpaceNotAllowedCreateError() - workspaces = FeatureService.get_system_features().license.workspaces + workspaces = FeatureService.get_license().workspaces if not workspaces.is_available(): raise WorkspacesLimitExceededError() @@ -2012,7 +2012,7 @@ class RegisterService: if ( FeatureService.get_system_features().is_allow_create_workspace and create_workspace_required - and FeatureService.get_system_features().license.workspaces.is_available() + and FeatureService.get_license().workspaces.is_available() ): try: TenantService.create_owner_tenant(account, session=session) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index b23638bba20..8a4b56b672c 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from configs import dify_config from constants.dsl_version import CURRENT_APP_DSL_VERSION from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition from enums.hosted_provider import HostedTrialProvider from services.billing_service import BillingInfo, BillingService from services.enterprise.enterprise_service import EnterpriseService @@ -75,8 +76,11 @@ class LicenseStatus(StrEnum): LOST = "lost" -class LicenseModel(FeatureResponseModel): +class LicenseStatusModel(FeatureResponseModel): status: LicenseStatus = LicenseStatus.NONE + + +class LicenseModel(LicenseStatusModel): expired_at: str = "" workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) @@ -162,6 +166,7 @@ class PluginManagerModel(FeatureResponseModel): class SystemFeatureModel(FeatureResponseModel): + deployment_edition: DeploymentEdition enable_app_deploy: bool = False sso_enforced_for_signin: bool = False sso_enforced_for_signin_protocol: str = "" @@ -174,7 +179,7 @@ class SystemFeatureModel(FeatureResponseModel): is_allow_register: bool = False is_allow_create_workspace: bool = False is_email_setup: bool = False - license: LicenseModel = LicenseModel() + license: LicenseStatusModel = LicenseStatusModel() branding: BrandingModel = BrandingModel() webapp_auth: WebAppAuthModel = WebAppAuthModel() plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() @@ -251,8 +256,8 @@ class FeatureService: ) @classmethod - def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: - system_features = SystemFeatureModel() + def get_system_features(cls) -> SystemFeatureModel: + system_features = SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION) system_features.rbac_enabled = dify_config.RBAC_ENABLED cls._fulfill_system_params_from_env(system_features) @@ -262,7 +267,7 @@ class FeatureService: system_features.webapp_auth.enabled = True system_features.enable_change_email = False system_features.plugin_manager.enabled = True - cls._fulfill_params_from_enterprise(system_features, is_authenticated) + cls._fulfill_params_from_enterprise(system_features) if dify_config.MARKETPLACE_ENABLED: system_features.enable_marketplace = True @@ -272,6 +277,17 @@ class FeatureService: return system_features + @classmethod + def get_license(cls) -> LicenseModel: + """Return full license detail. Enterprise-only; requires an authenticated caller. + + Non-enterprise deployments have no license, so an unconstrained default + (unlimited seats/workspaces) is returned. + """ + if not dify_config.ENTERPRISE_ENABLED: + return LicenseModel() + return cls._build_license(EnterpriseService.get_info()) + @classmethod def get_app_dsl_version(cls) -> str: return CURRENT_APP_DSL_VERSION @@ -285,6 +301,7 @@ class FeatureService: system_features.is_allow_register = dify_config.ALLOW_REGISTER system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" + system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP @@ -410,7 +427,27 @@ class FeatureService: vector_space.limit = billing_info["vector_space"]["limit"] @classmethod - def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel, is_authenticated: bool = False): + def _build_license(cls, enterprise_info: dict) -> LicenseModel: + license_model = LicenseModel() + if license_info := enterprise_info.get("License"): + license_model.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) + license_model.expired_at = license_info.get("expiredAt", "") + if workspaces_info := license_info.get("workspaces"): + license_model.workspaces = LicenseLimitationModel( + enabled=workspaces_info.get("enabled", False), + limit=workspaces_info.get("limit", 0), + size=workspaces_info.get("used", 0), + ) + if seats_info := license_info.get("licensedSeats"): + license_model.seats = LicenseLimitationModel( + enabled=seats_info.get("enabled", False), + limit=seats_info.get("limit", 0), + size=seats_info.get("used", 0), + ) + return license_model + + @classmethod + def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel): enterprise_info = EnterpriseService.get_info() if "SSOEnforcedForSignin" in enterprise_info: @@ -450,24 +487,14 @@ class FeatureService: ) features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "") - # SECURITY NOTE: Only license *status* is exposed to unauthenticated callers - # so the login page can detect an expired/inactive license after force-logout. - # All other license details (expiry date, workspace usage) remain auth-gated. - # This behavior reflects prior internal review of information-leakage risks. + # SECURITY NOTE: system-features is unauthenticated, so it exposes only license + # *status* — enough for the login page to detect an expired/inactive license after + # force-logout. Full license detail (expiry, workspace/seat usage) is served + # separately by get_license() behind an authenticated endpoint. if license_info := enterprise_info.get("License"): - features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) - - if is_authenticated: - features.license.expired_at = license_info.get("expiredAt", "") - if workspaces_info := license_info.get("workspaces"): - features.license.workspaces.enabled = workspaces_info.get("enabled", False) - features.license.workspaces.limit = workspaces_info.get("limit", 0) - features.license.workspaces.size = workspaces_info.get("used", 0) - - if seats_info := license_info.get("licensedSeats"): - features.license.seats.enabled = seats_info.get("enabled", False) - features.license.seats.limit = seats_info.get("limit", 0) - features.license.seats.size = seats_info.get("used", 0) + features.license = LicenseStatusModel( + status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) + ) if "PluginInstallationPermission" in enterprise_info: plugin_installation_info = enterprise_info["PluginInstallationPermission"] diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index 39938aad9d3..5f9003bb755 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from configs import dify_config from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition from models.account import Tenant, TenantAccountJoin, TenantAccountRole from services.account_service import TenantService from services.feature_service import FeatureService @@ -60,7 +61,7 @@ class WorkspaceService: "remove_webapp_brand": remove_webapp_brand, "replace_webapp_logo": replace_webapp_logo, } - if dify_config.EDITION == "CLOUD": + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: tenant_info["next_credit_reset_date"] = feature.next_credit_reset_date from services.credit_pool_service import CreditPoolBalance, CreditPoolService diff --git a/api/tasks/workflow_cfs_scheduler/entities.py b/api/tasks/workflow_cfs_scheduler/entities.py index 6990f6968a9..e95d606731c 100644 --- a/api/tasks/workflow_cfs_scheduler/entities.py +++ b/api/tasks/workflow_cfs_scheduler/entities.py @@ -1,10 +1,11 @@ from enum import StrEnum from configs import dify_config +from enums.deployment_edition import DeploymentEdition from services.workflow.entities import WorkflowScheduleCFSPlanEntity # Determine queue names based on edition -if dify_config.EDITION == "CLOUD": +if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: # Cloud edition: separate queues for different tiers _professional_queue = "workflow_professional" _team_queue = "workflow_team" diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 24895e562e5..4579fbfbb40 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -38,7 +38,8 @@ class TestAccountService: # Setup default mock returns mock_feature_service.get_system_features.return_value.is_allow_register = True mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True - mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.seats.is_available.return_value = True mock_billing_service.is_email_in_freeze.return_value = False mock_passport_service.return_value.issue.return_value = "mock_jwt_token" @@ -405,7 +406,7 @@ class TestAccountService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False account = AccountService.create_account_and_tenant( email=email, @@ -466,7 +467,7 @@ class TestAccountService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = False + ].get_license.return_value.workspaces.is_available.return_value = False mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False with pytest.raises(WorkspacesLimitExceededError): @@ -492,7 +493,7 @@ class TestAccountService: mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.seats.is_available.return_value = False + ].get_license.return_value.seats.is_available.return_value = False mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False with pytest.raises(SeatsLimitExceededError): @@ -1274,7 +1275,8 @@ class TestTenantService: ): # Setup default mock returns mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True - mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.seats.is_available.return_value = True mock_billing_service.is_email_in_freeze.return_value = False yield { @@ -2244,7 +2246,7 @@ class TestTenantService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True # Create account account = AccountService.create_account( @@ -2285,7 +2287,7 @@ class TestTenantService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True # Create account and existing tenant account = AccountService.create_account( @@ -2514,7 +2516,8 @@ class TestRegisterService: # Setup default mock returns mock_feature_service.get_system_features.return_value.is_allow_register = True mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True - mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True + mock_feature_service.get_license.return_value.seats.is_available.return_value = True mock_billing_service.is_email_in_freeze.return_value = False mock_passport_service.return_value.issue.return_value = "mock_jwt_token" @@ -2631,7 +2634,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Execute registration @@ -2675,7 +2678,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Execute registration with OAuth @@ -2724,7 +2727,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Execute registration with pending status @@ -2809,7 +2812,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = False + ].get_license.return_value.workspaces.is_available.return_value = False mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # with pytest.raises(AccountRegisterError, match="Workspace is not allowed to create."): @@ -2888,7 +2891,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Create tenant and inviter account diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index 4e5bfc6ac1d..a7e2584c14b 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -5,10 +5,12 @@ from faker import Faker from sqlalchemy.orm import Session from enums.cloud_plan import CloudPlan +from enums.deployment_edition import DeploymentEdition from services.feature_service import ( FeatureModel, FeatureService, KnowledgeRateLimitModel, + LicenseModel, LicenseStatus, SystemFeatureModel, ) @@ -273,6 +275,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True @@ -285,7 +288,7 @@ class TestFeatureService: mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100 # Act: Execute the method under test - result = FeatureService.get_system_features(is_authenticated=True) + result = FeatureService.get_system_features() # Assert: Verify the expected outcomes assert result is not None @@ -320,12 +323,9 @@ class TestFeatureService: assert result.webapp_auth.allow_email_password_login is False assert result.webapp_auth.sso_config.protocol == "oidc" - # Verify license configuration + # Verify license status (public system-features exposes status only; detail lives on get_license) assert result.license.status.value == "active" - assert result.license.expired_at == "2025-12-31" - assert result.license.workspaces.enabled is True - assert result.license.workspaces.limit == 5 - assert result.license.workspaces.size == 2 + assert not hasattr(result.license, "expired_at") # Verify plugin installation permission assert result.plugin_installation_permission.plugin_installation_scope == "official_only" @@ -350,6 +350,7 @@ class TestFeatureService: """ # Arrange: Setup test data with exact same config as success test with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True @@ -360,20 +361,18 @@ class TestFeatureService: mock_config.MAIL_TYPE = "smtp" mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100 - # Act: Execute with is_authenticated=False - result = FeatureService.get_system_features(is_authenticated=False) + # Act: Execute the public (unauthenticated) system-features call + result = FeatureService.get_system_features() # Assert: Basic structure assert result is not None assert isinstance(result, SystemFeatureModel) # --- 1. Verify only license *status* is exposed to unauthenticated clients --- - # Detailed license info (expiry, workspaces) remains auth-gated. + # Detailed license info (expiry, workspaces) is not part of the public model. assert result.license.status == LicenseStatus.ACTIVE - assert result.license.expired_at == "" - assert result.license.workspaces.enabled is False - assert result.license.workspaces.limit == 0 - assert result.license.workspaces.size == 0 + assert not hasattr(result.license, "expired_at") + assert not hasattr(result.license, "workspaces") # --- 2. Verify Public UI Configuration Availability --- # Ensure that data required for frontend rendering remains accessible. @@ -394,6 +393,47 @@ class TestFeatureService: # Marketplace should be visible assert result.enable_marketplace is True + def test_get_license_returns_full_detail( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test the authenticated license accessor. + + This test verifies that: + - get_license() returns the full license payload (status, expiry, workspace usage). + - Detail withheld from the public system-features model is present here. + """ + # Arrange + with patch("services.feature_service.dify_config") as mock_config: + mock_config.ENTERPRISE_ENABLED = True + + # Act + result = FeatureService.get_license() + + # Assert: full license detail is populated + assert isinstance(result, LicenseModel) + assert result.status == LicenseStatus.ACTIVE + assert result.expired_at == "2025-12-31" + assert result.workspaces.enabled is True + assert result.workspaces.limit == 5 + assert result.workspaces.size == 2 + mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() + + def test_get_license_non_enterprise_is_unconstrained( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """Non-enterprise deployments have no license, so limits are unconstrained.""" + with patch("services.feature_service.dify_config") as mock_config: + mock_config.ENTERPRISE_ENABLED = False + + result = FeatureService.get_license() + + assert isinstance(result, LicenseModel) + assert result.status == LicenseStatus.NONE + assert result.workspaces.is_available() is True + assert result.seats.is_available() is True + mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called() + def test_get_system_features_basic_config( self, db_session_with_containers: Session, mock_external_service_dependencies ): @@ -408,12 +448,14 @@ class TestFeatureService: """ # Arrange: Setup basic config mock (no enterprise) with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.ENTERPRISE_ENABLED = False mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False mock_config.ENABLE_COLLABORATION_MODE = False + mock_config.ENABLE_CHANGE_EMAIL = True mock_config.ALLOW_REGISTER = True mock_config.ALLOW_CREATE_WORKSPACE = True mock_config.MAIL_TYPE = "smtp" @@ -611,11 +653,13 @@ class TestFeatureService: """ # Arrange: Setup enterprise disabled mock with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.ENTERPRISE_ENABLED = False mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = True + mock_config.ENABLE_CHANGE_EMAIL = True mock_config.ALLOW_REGISTER = False mock_config.ALLOW_CREATE_WORKSPACE = False mock_config.MAIL_TYPE = None @@ -650,8 +694,8 @@ class TestFeatureService: # Verify default license status assert result.license.status == "none" - assert result.license.expired_at == "" - assert result.license.workspaces.enabled is False + assert not hasattr(result.license, "expired_at") + assert not hasattr(result.license, "workspaces") # Verify no enterprise service calls mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called() @@ -840,6 +884,7 @@ class TestFeatureService: """ # Arrange: Setup edge case webapp auth mock with proper config with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -960,6 +1005,7 @@ class TestFeatureService: # Test case 1: Official only scope with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -983,6 +1029,7 @@ class TestFeatureService: # Test case 2: All plugins scope with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1003,6 +1050,7 @@ class TestFeatureService: # Test case 3: Specific partners scope with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1026,6 +1074,7 @@ class TestFeatureService: # Test case 4: None scope with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1118,24 +1167,19 @@ class TestFeatureService: } } - # Act: Execute the method under test - result = FeatureService.get_system_features(is_authenticated=True) + # Act: Execute the authenticated license accessor + result = FeatureService.get_license() # Assert: Verify the expected outcomes assert result is not None - assert isinstance(result, SystemFeatureModel) + assert isinstance(result, LicenseModel) - # Verify license status - assert result.license.status == "inactive" - assert result.license.expired_at == "" - assert result.license.workspaces.enabled is False - assert result.license.workspaces.size == 0 - assert result.license.workspaces.limit == 0 - - # Verify enterprise features - assert result.branding.enabled is True - assert result.webapp_auth.enabled is True - assert result.enable_change_email is False + # Verify license status and detail + assert result.status == "inactive" + assert result.expired_at == "" + assert result.workspaces.enabled is False + assert result.workspaces.size == 0 + assert result.workspaces.limit == 0 # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() @@ -1154,6 +1198,7 @@ class TestFeatureService: """ # Arrange: Setup partial enterprise info mock with proper config with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1200,8 +1245,8 @@ class TestFeatureService: # Verify default license status assert result.license.status == "none" - assert result.license.expired_at == "" - assert result.license.workspaces.enabled is False + assert not hasattr(result.license, "expired_at") + assert not hasattr(result.license, "workspaces") # Verify default plugin installation permission assert result.plugin_installation_permission.plugin_installation_scope == "all" @@ -1283,6 +1328,7 @@ class TestFeatureService: """ # Arrange: Setup edge case protocols mock with proper config with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1493,24 +1539,19 @@ class TestFeatureService: } } - # Act: Execute the method under test - result = FeatureService.get_system_features(is_authenticated=True) + # Act: Execute the authenticated license accessor + result = FeatureService.get_license() # Assert: Verify the expected outcomes assert result is not None - assert isinstance(result, SystemFeatureModel) + assert isinstance(result, LicenseModel) - # Verify license status - assert result.license.status == "expired" - assert result.license.expired_at == "2023-12-31" - assert result.license.workspaces.enabled is False - assert result.license.workspaces.size == 0 - assert result.license.workspaces.limit == 0 - - # Verify enterprise features - assert result.branding.enabled is True - assert result.webapp_auth.enabled is True - assert result.enable_change_email is False + # Verify license status and detail + assert result.status == "expired" + assert result.expired_at == "2023-12-31" + assert result.workspaces.enabled is False + assert result.workspaces.size == 0 + assert result.workspaces.limit == 0 # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() @@ -1587,6 +1628,7 @@ class TestFeatureService: """ # Arrange: Setup edge case branding mock with proper config with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1776,6 +1818,7 @@ class TestFeatureService: """ # Arrange: Setup lost license mock with proper config with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False @@ -1787,7 +1830,7 @@ class TestFeatureService: mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100 mock_external_service_dependencies["enterprise_service"].get_info.return_value = { - "license": {"status": "lost", "expired_at": None, "plan": None} + "License": {"status": "lost"} } # Act: Execute the method under test @@ -1809,6 +1852,7 @@ class TestFeatureService: assert result.enable_email_password_login is True assert result.is_allow_register is False assert result.is_allow_create_workspace is False + assert result.license.status == LicenseStatus.LOST # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/services/test_workspace_service.py b/api/tests/test_containers_integration_tests/services/test_workspace_service.py index bbbbd735e59..f22775a13eb 100644 --- a/api/tests/test_containers_integration_tests/services/test_workspace_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workspace_service.py @@ -6,6 +6,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from enums.deployment_edition import DeploymentEdition from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from services.credit_pool_service import CreditPoolBalance from services.workspace_service import WorkspaceService @@ -611,7 +612,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "SELF_HOSTED" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False mock_external_service_dependencies["tenant_service"].has_roles.return_value = False @@ -632,7 +633,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -657,7 +658,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -685,7 +686,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -713,7 +714,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -749,7 +750,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -779,7 +780,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" @@ -808,7 +809,7 @@ class TestWorkspaceService: db_session_with_containers, mock_external_service_dependencies ) - mock_external_service_dependencies["dify_config"].EDITION = "CLOUD" + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD feature = mock_external_service_dependencies["feature_service"].get_features.return_value feature.can_replace_logo = False feature.next_credit_reset_date = "2025-02-01" diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 3b675600539..e60656201ff 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -13,6 +13,7 @@ from sqlalchemy import Engine, event from sqlalchemy.orm import Session from controllers.console.app import app_import as app_import_module +from enums.deployment_edition import DeploymentEdition from models.account import Account from models.base import TypeBase from models.engine import db @@ -47,7 +48,10 @@ class _Result: def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: - features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled)) + features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + webapp_auth=WebAppAuthModel(enabled=enabled), + ) monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 468d98ab377..4c0b27d554f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -11,6 +11,7 @@ from controllers.console.auth.email_register import ( EmailRegisterResetApi, EmailRegisterSendEmailApi, ) +from enums.deployment_edition import DeploymentEdition from services.feature_service import SystemFeatureModel @@ -34,7 +35,11 @@ class TestEmailRegisterSendEmailApi: mock_account = MagicMock() mock_get_account.return_value = mock_account - feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) + feature_flags = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) with ( patch("controllers.console.auth.email_register.dify_config.BILLING_ENABLED", True), patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), @@ -75,7 +80,11 @@ class TestEmailRegisterCheckApi: mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"} mock_generate_token.return_value = (None, "new-token") - feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) + feature_flags = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), @@ -123,7 +132,11 @@ class TestEmailRegisterResetApi: mock_login.return_value = token_pair mock_get_account.return_value = None - feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) + feature_flags = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), @@ -171,7 +184,11 @@ class TestEmailRegisterResetApi: mock_login.return_value = token_pair mock_get_account.return_value = None - feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) + feature_flags = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), @@ -224,7 +241,11 @@ class TestEmailRegisterResetApi: mock_login.return_value = token_pair mock_get_account.return_value = None - feature_flags = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) + feature_flags = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index e56ed48bcfe..cb79ccc1835 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -485,10 +485,12 @@ class TestEmailCodeLoginApi: @patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token") @patch("controllers.console.auth.login.AccountService.get_user_through_email") @patch("controllers.console.auth.login.TenantService.get_join_tenants") + @patch("controllers.console.auth.login.FeatureService.get_license") @patch("controllers.console.auth.login.FeatureService.get_system_features") def test_email_code_login_workspace_limit_exceeded( self, mock_get_features, + mock_get_license, mock_get_tenants, mock_get_user, mock_revoke_token, @@ -507,9 +509,7 @@ class TestEmailCodeLoginApi: mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] - mock_features = MagicMock() - mock_features.license.workspaces.is_available.return_value = False - mock_get_features.return_value = mock_features + mock_get_license.return_value.workspaces.is_available.return_value = False # Act & Assert with app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py index 5253d0dfe06..48a6b98a1c3 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py @@ -13,6 +13,7 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordResetApi, ForgotPasswordSendEmailApi, ) +from enums.deployment_edition import DeploymentEdition from models.account import Account from models.engine import db from services.feature_service import SystemFeatureModel @@ -46,8 +47,15 @@ class TestForgotPasswordSendEmailApi: mock_get_account.return_value = mock_account mock_send_email.return_value = "token-123" - wraps_features = SystemFeatureModel(enable_email_password_login=True, is_allow_register=True) - controller_features = SystemFeatureModel(is_allow_register=True) + wraps_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + is_allow_register=True, + ) + controller_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + is_allow_register=True, + ) with ( patch( "controllers.console.auth.forgot_password.FeatureService.get_system_features", @@ -95,7 +103,10 @@ class TestForgotPasswordCheckApi: mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"} mock_generate_token.return_value = (None, "new-token") - wraps_features = SystemFeatureModel(enable_email_password_login=True) + wraps_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), @@ -138,7 +149,10 @@ class TestForgotPasswordResetApi: db.session.commit() mock_get_account.return_value = account - wraps_features = SystemFeatureModel(enable_email_password_login=True) + wraps_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + ) with ( patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index ecb3e694cd3..cb0419c4acc 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -343,10 +343,12 @@ class TestLoginApi: @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.TenantService.get_join_tenants") + @patch("controllers.console.auth.login.FeatureService.get_license") @patch("controllers.console.auth.login.FeatureService.get_system_features") def test_login_fails_when_no_workspace_and_limit_exceeded( self, mock_get_features: MagicMock, + mock_get_license: MagicMock, mock_get_tenants: MagicMock, mock_authenticate: MagicMock, mock_get_invitation: MagicMock, @@ -370,8 +372,8 @@ class TestLoginApi: mock_features = MagicMock() mock_features.is_allow_create_workspace = True - mock_features.license.workspaces.is_available.return_value = False mock_get_features.return_value = mock_features + mock_get_license.return_value.workspaces.is_available.return_value = False # Act & Assert with app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py index 0ee9cf5248b..6da8cb5a51d 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -23,6 +23,7 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError +from enums.deployment_edition import DeploymentEdition from models.account import Account, Tenant, TenantAccountJoin from services.feature_service import SystemFeatureModel @@ -48,7 +49,10 @@ def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD") monkeypatch.setattr( "controllers.console.wraps.FeatureService.get_system_features", - lambda: SystemFeatureModel(enable_email_password_login=True), + lambda: SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + ), ) diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 5b524cf9c64..66fb0b00f74 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -2,14 +2,15 @@ from inspect import unwrap from pytest_mock import MockerFixture -from models import Account -from services.feature_service import FeatureModel, LimitationModel, SystemFeatureModel - - -def make_account() -> Account: - account = Account(name="Alice", email="alice@example.com") - account.id = "account-1" - return account +from enums.deployment_edition import DeploymentEdition +from services.feature_service import ( + FeatureModel, + LicenseLimitationModel, + LicenseModel, + LicenseStatus, + LimitationModel, + SystemFeatureModel, +) class TestFeatureApi: @@ -82,19 +83,16 @@ class TestAppDslVersionApi: class TestSystemFeatureApi: - def test_get_system_features_authenticated(self, mocker: MockerFixture): - """ - current_user.is_authenticated == True - """ + def test_get_system_features_public(self, mocker: MockerFixture): + """The public endpoint returns system features without any authentication input.""" from controllers.console.feature import SystemFeatureApi - account = make_account() - current_account = mocker.patch( - "controllers.console.feature.current_account_with_tenant_optional", - return_value=(account, "tenant-123"), + system_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + is_allow_register=True, + enable_learn_app=True, ) - system_features = SystemFeatureModel(is_allow_register=True, enable_learn_app=True) get_system_features = mocker.patch( "controllers.console.feature.FeatureService.get_system_features", return_value=system_features, @@ -105,29 +103,28 @@ class TestSystemFeatureApi: assert result == system_features.model_dump() assert result["enable_learn_app"] is True - current_account.assert_called_once_with() - get_system_features.assert_called_once_with(is_authenticated=True) + assert result["license"] == {"status": LicenseStatus.NONE} + get_system_features.assert_called_once_with() - def test_get_system_features_unauthenticated(self, mocker: MockerFixture): - """ - current_user.is_authenticated raises Unauthorized - """ - from controllers.console.feature import SystemFeatureApi +class TestSystemFeatureLicenseApi: + def test_get_license_success(self, mocker: MockerFixture): + from controllers.console.feature import SystemFeatureLicenseApi - current_account = mocker.patch( - "controllers.console.feature.current_account_with_tenant_optional", - return_value=(None, None), + license_model = LicenseModel( + status=LicenseStatus.ACTIVE, + expired_at="2025-12-31", + seats=LicenseLimitationModel(enabled=True, limit=5, size=2), ) - system_features = SystemFeatureModel(is_allow_register=False) - get_system_features = mocker.patch( - "controllers.console.feature.FeatureService.get_system_features", - return_value=system_features, + get_license = mocker.patch( + "controllers.console.feature.FeatureService.get_license", + return_value=license_model, ) - api = SystemFeatureApi() - result = api.get() + api = SystemFeatureLicenseApi() + raw_get = unwrap(SystemFeatureLicenseApi.get) + result = raw_get(api) - assert result == system_features.model_dump() - current_account.assert_called_once_with() - get_system_features.assert_called_once_with(is_authenticated=False) + assert result == license_model.model_dump() + assert result["seats"] == {"enabled": True, "limit": 5, "size": 2} + get_license.assert_called_once_with() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py index 652d1188ab4..58f8468429a 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -323,8 +323,8 @@ class TestMemberInviteEmailApi: features = MagicMock() features.billing.enabled = False features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com", "b@test.com"], @@ -336,9 +336,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 2)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.FeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member") as mock_invite, patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), @@ -346,8 +346,8 @@ class TestMemberInviteEmailApi: with pytest.raises(SeatsLimitExceeded): method(api, user) - mock_get_system_features.assert_called_once_with(is_authenticated=True) - system_features.license.seats.is_available.assert_called_once_with(2) + mock_get_license.assert_called_once_with() + license_info.seats.is_available.assert_called_once_with(2) mock_invite.assert_not_called() def test_invite_existing_accounts_do_not_consume_seats(self, app: Flask): @@ -359,8 +359,8 @@ class TestMemberInviteEmailApi: features = MagicMock() features.billing.enabled = False features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com", "b@test.com"], @@ -372,9 +372,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 0)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.FeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch( "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, @@ -386,8 +386,8 @@ class TestMemberInviteEmailApi: assert status == 201 assert len(result["invitation_results"]) == 2 - mock_get_system_features.assert_not_called() - system_features.license.seats.is_available.assert_not_called() + mock_get_license.assert_not_called() + license_info.seats.is_available.assert_not_called() assert mock_invite.call_count == 2 def test_invite_mixed_accounts_with_available_seats(self, app: Flask): @@ -399,8 +399,8 @@ class TestMemberInviteEmailApi: features = MagicMock() features.billing.enabled = False features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = True + license_info = MagicMock() + license_info.seats.is_available.return_value = True payload = { "emails": ["a@test.com", "b@test.com"], @@ -412,9 +412,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.FeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch( "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, @@ -426,8 +426,8 @@ class TestMemberInviteEmailApi: assert status == 201 assert len(result["invitation_results"]) == 2 - mock_get_system_features.assert_called_once_with(is_authenticated=True) - system_features.license.seats.is_available.assert_called_once_with(1) + mock_get_license.assert_called_once_with() + license_info.seats.is_available.assert_called_once_with(1) assert mock_invite.call_count == 2 def test_invite_skips_seats_limit_when_enterprise_disabled(self, app: Flask): @@ -439,8 +439,8 @@ class TestMemberInviteEmailApi: features = MagicMock() features.billing.enabled = False features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = False + license_info = MagicMock() + license_info.seats.is_available.return_value = False payload = { "emails": ["a@test.com"], @@ -452,9 +452,9 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, - ) as mock_get_system_features, + "controllers.console.workspace.members.FeatureService.get_license", + return_value=license_info, + ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), @@ -464,8 +464,8 @@ class TestMemberInviteEmailApi: assert status == 201 assert result["invitation_results"][0]["status"] == "success" - mock_get_system_features.assert_not_called() - system_features.license.seats.is_available.assert_not_called() + mock_get_license.assert_not_called() + license_info.seats.is_available.assert_not_called() def test_invite_seats_error_is_reported_as_failed_result(self, app: Flask): api = MemberInviteEmailApi() @@ -476,8 +476,8 @@ class TestMemberInviteEmailApi: features = MagicMock() features.billing.enabled = False features.workspace_members.enabled = False - system_features = MagicMock() - system_features.license.seats.is_available.return_value = True + license_info = MagicMock() + license_info.seats.is_available.return_value = True payload = { "emails": ["a@test.com"], @@ -489,8 +489,8 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), patch( - "controllers.console.workspace.members.FeatureService.get_system_features", - return_value=system_features, + "controllers.console.workspace.members.FeatureService.get_license", + return_value=license_info, ), patch( "controllers.console.workspace.members.RegisterService.invite_new_member", diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_data.py b/api/tests/unit_tests/controllers/openapi/auth/test_data.py index 14167ec3ac7..7ee1a83ec00 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_data.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_data.py @@ -11,26 +11,27 @@ from controllers.openapi.auth.data import ( RequestContext, current_edition, ) +from enums.deployment_edition import DeploymentEdition from libs.oauth_bearer import Scope, TokenType def test_current_edition_saas(): with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.EDITION = "CLOUD" + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD cfg.ENTERPRISE_ENABLED = True assert current_edition() == Edition.SAAS def test_current_edition_ee(): with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.EDITION = "SELF_HOSTED" + cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE cfg.ENTERPRISE_ENABLED = True assert current_edition() == Edition.EE def test_current_edition_ce(): with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.EDITION = "SELF_HOSTED" + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY cfg.ENTERPRISE_ENABLED = False assert current_edition() == Edition.CE diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index ffd51f81b0d..0d6fa621276 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -14,6 +14,7 @@ from controllers.web.forgot_password import ( ForgotPasswordResetApi, ForgotPasswordSendEmailApi, ) +from enums.deployment_edition import DeploymentEdition from models.account import Account from models.engine import db from services.feature_service import SystemFeatureModel @@ -32,7 +33,10 @@ def database_app() -> Iterator[Flask]: @pytest.fixture(autouse=True) def _patch_wraps(): - wraps_features = SystemFeatureModel(enable_email_password_login=True) + wraps_features = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_email_password_login=True, + ) with ( patch("controllers.console.wraps.db") as mock_db, patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True), diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index a91d4253aa8..2ccb6b832fc 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -10,6 +10,7 @@ from werkzeug.exceptions import Unauthorized import services.errors.account from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi +from enums.deployment_edition import DeploymentEdition from services.entities.auth_entities import LoginFailureReason @@ -34,7 +35,7 @@ def app(): @pytest.fixture(autouse=True) def _patch_wraps(): wraps_features = SimpleNamespace(enable_email_password_login=True) - console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, EDITION="CLOUD") + console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True) with ( patch("controllers.console.wraps.db") as mock_db, diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py index cd5927583ea..ca1a227b010 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py @@ -19,6 +19,7 @@ from sqlalchemy.orm import Session from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginVerification from core.plugin.plugin_service import PluginService +from enums.deployment_edition import DeploymentEdition from models import ProviderType from models.engine import db from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider @@ -35,10 +36,11 @@ def _make_features( scope: PluginInstallationScope = PluginInstallationScope.ALL, ) -> SystemFeatureModel: return SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, plugin_installation_permission=PluginInstallationPermissionModel( restrict_to_marketplace_only=restrict_to_marketplace, plugin_installation_scope=scope, - ) + ), ) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 13715f02820..bc8f4c763a7 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -808,7 +808,7 @@ class TestTenantService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_rsa_dependencies.return_value = "mock_public_key" with ( @@ -1028,7 +1028,7 @@ class TestTenantService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_tenant = MagicMock() mock_tenant.id = "tenant-rbac" @@ -1481,7 +1481,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account @@ -1590,7 +1590,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1629,7 +1629,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False mock_account = TestAccountAssociatedDataFactory.create_account_mock( @@ -1664,7 +1664,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account and link_account_integrate @@ -1708,7 +1708,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account @@ -1750,7 +1750,7 @@ class TestRegisterService: ].get_system_features.return_value.is_allow_create_workspace = True mock_external_service_dependencies[ "feature_service" - ].get_system_features.return_value.license.workspaces.is_available.return_value = True + ].get_license.return_value.workspaces.is_available.return_value = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False # Mock AccountService.create_account diff --git a/api/tests/unit_tests/services/test_feature_service_change_email.py b/api/tests/unit_tests/services/test_feature_service_change_email.py new file mode 100644 index 00000000000..b6b6059cd57 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_change_email.py @@ -0,0 +1,15 @@ +import pytest + +from services.feature_service import FeatureService + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_get_system_features_reads_enable_change_email( + monkeypatch: pytest.MonkeyPatch, + enabled: bool, +) -> None: + monkeypatch.setattr("services.feature_service.dify_config.ENABLE_CHANGE_EMAIL", enabled) + + result = FeatureService.get_system_features() + + assert result.enable_change_email is enabled diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py new file mode 100644 index 00000000000..966247c610c --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py @@ -0,0 +1,35 @@ +import pytest +from pydantic import ValidationError + +from enums.deployment_edition import DeploymentEdition +from services.feature_service import FeatureService, SystemFeatureModel + + +def test_system_feature_model_requires_deployment_edition() -> None: + with pytest.raises(ValidationError): + SystemFeatureModel.model_validate({}) + + +@pytest.mark.parametrize( + ("edition", "enterprise_enabled", "expected"), + [ + ("SELF_HOSTED", False, DeploymentEdition.COMMUNITY), + ("SELF_HOSTED", True, DeploymentEdition.ENTERPRISE), + ("CLOUD", False, DeploymentEdition.CLOUD), + ("CLOUD", True, DeploymentEdition.CLOUD), + ], +) +def test_get_system_features_resolves_deployment_edition( + monkeypatch: pytest.MonkeyPatch, + edition: str, + enterprise_enabled: bool, + expected: DeploymentEdition, +) -> None: + monkeypatch.setattr("services.feature_service.dify_config.EDITION", edition) + monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None) + + result = FeatureService.get_system_features() + + assert result.deployment_edition is expected + assert result.model_dump(mode="json")["deployment_edition"] == expected.value diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py index 1c3b4fdbc1f..0f0fa1e1dba 100644 --- a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py +++ b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py @@ -1,5 +1,6 @@ import pytest +from enums.deployment_edition import DeploymentEdition from services import feature_service as feature_service_module from services.feature_service import FeatureService, SystemFeatureModel @@ -29,7 +30,7 @@ def test_fulfill_params_from_enterprise_enable_app_deploy( staticmethod(lambda: enterprise_info), ) - features = SystemFeatureModel() + features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) features.enable_app_deploy = initial FeatureService._fulfill_params_from_enterprise(features) diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py index 63bfc739659..c151edf5406 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py @@ -1,10 +1,11 @@ import pytest +from enums.deployment_edition import DeploymentEdition from services.feature_service import FeatureService, SystemFeatureModel def test_system_feature_model_disables_knowledge_fs_by_default() -> None: - assert SystemFeatureModel().knowledge_fs_enabled is False + assert SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY).knowledge_fs_enabled is False @pytest.mark.parametrize("enabled", [False, True]) diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py index 61616b84356..bc33bfabbc1 100644 --- a/api/tests/unit_tests/services/test_feature_service_learn_app.py +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -1,12 +1,15 @@ import pytest +from enums.deployment_edition import DeploymentEdition from services import feature_service as feature_service_module from services.feature_service import FeatureService, SystemFeatureModel def test_system_feature_model_defaults_enable_learn_app(): - assert SystemFeatureModel().enable_learn_app is True - assert SystemFeatureModel().enable_step_by_step_tour is False + system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) + + assert system_features.enable_learn_app is True + assert system_features.enable_step_by_step_tour is False @pytest.mark.parametrize("enabled", [True, False]) diff --git a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py index aa593f6279d..fb1a5139118 100644 --- a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py +++ b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py @@ -1,38 +1,34 @@ import pytest from services import feature_service as feature_service_module -from services.feature_service import FeatureService, SystemFeatureModel +from services.feature_service import FeatureService, LicenseModel, LicenseStatus _ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}} -def test_fulfill_params_from_enterprise_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): - """Authenticated fill copies the licensed-seat quota out of the enterprise payload.""" +def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): + """The authenticated license accessor copies the licensed-seat quota out of the enterprise payload.""" + monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", staticmethod(lambda: _ENTERPRISE_INFO), ) - features = SystemFeatureModel() - FeatureService._fulfill_params_from_enterprise(features, is_authenticated=True) + license_model = FeatureService.get_license() - assert features.license.seats.enabled is True - assert features.license.seats.limit == 3 - assert features.license.seats.size == 1 + assert isinstance(license_model, LicenseModel) + assert license_model.seats.enabled is True + assert license_model.seats.limit == 3 + assert license_model.seats.size == 1 -def test_fulfill_params_from_enterprise_withholds_seats_when_unauthenticated(monkeypatch: pytest.MonkeyPatch): - """Seat counts are auth-gated: unauthenticated callers keep the zeroed default.""" - monkeypatch.setattr( - feature_service_module.EnterpriseService, - "get_info", - staticmethod(lambda: _ENTERPRISE_INFO), - ) +def test_get_license_non_enterprise_is_unconstrained(monkeypatch: pytest.MonkeyPatch): + """Non-enterprise deployments have no license; seat allocation is unconstrained.""" + monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False) - features = SystemFeatureModel() - FeatureService._fulfill_params_from_enterprise(features, is_authenticated=False) + license_model = FeatureService.get_license() - assert features.license.seats.enabled is False - assert features.license.seats.limit == 0 - assert features.license.seats.size == 0 + assert license_model.status == LicenseStatus.NONE + assert license_model.seats.enabled is False + assert license_model.seats.is_available() is True diff --git a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py index a58aa44d323..510acd67332 100644 --- a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py +++ b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py @@ -1,5 +1,6 @@ import pytest +from enums.deployment_edition import DeploymentEdition from services.feature_service import FeatureService, SystemFeatureModel @@ -18,7 +19,7 @@ def test_fulfill_system_params_from_env_sets_allow_public_access( ): monkeypatch.setattr("services.feature_service.dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED", env_value) - system_features = SystemFeatureModel() + system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) FeatureService._fulfill_system_params_from_env(system_features) assert system_features.webapp_auth.allow_public_access is expected diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py index 5f08160f67f..827ded2d61a 100644 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ b/api/tests/unit_tests/services/test_recommended_app_service.py @@ -10,6 +10,7 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import Session +from enums.deployment_edition import DeploymentEdition from models.model import AccountTrialAppRecord, App, AppMode, TrialApp from services import recommended_app_service as service_module from services.feature_service import SystemFeatureModel @@ -298,7 +299,10 @@ class TestRecommendedAppServiceGetDetail: sqlite_session: Session, ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False) + mock_feature_service.get_system_features.return_value = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=False, + ) cases: list[tuple[str, RecommendedAppPayload]] = [ ( "complex-app", @@ -337,7 +341,10 @@ class TestRecommendedAppServiceGetDetail: mock_feature_service: MagicMock, sqlite_session: Session, ) -> None: - mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False) + mock_feature_service.get_system_features.return_value = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=False, + ) for mode in ["remote", "builtin", "db"]: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode detail = _app_detail(app_id="test-app", name=f"App from {mode}") @@ -367,7 +374,10 @@ class TestRecommendedAppServiceGetLearnDifyApps: sqlite_session: Session, ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_feature_service.get_system_features.return_value = SystemFeatureModel(enable_trial_app=False) + mock_feature_service.get_system_features.return_value = SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=False, + ) expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") mock_instance = MagicMock() mock_instance.get_learn_dify_apps.return_value = { @@ -402,7 +412,12 @@ class TestRecommendedAppServiceGetLearnDifyApps: monkeypatch.setattr( service_module.FeatureService, "get_system_features", - MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)), + MagicMock( + return_value=SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=True, + ) + ), ) can_trial_mock = MagicMock(return_value=True) monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock) @@ -425,7 +440,12 @@ class TestRecommendedAppServiceTrialFeatures: monkeypatch.setattr( service_module.FeatureService, "get_system_features", - MagicMock(return_value=SystemFeatureModel(enable_trial_app=False)), + MagicMock( + return_value=SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=False, + ) + ), ) result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session) @@ -456,7 +476,12 @@ class TestRecommendedAppServiceTrialFeatures: monkeypatch.setattr( service_module.FeatureService, "get_system_features", - MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)), + MagicMock( + return_value=SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=True, + ) + ), ) result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session) @@ -492,7 +517,12 @@ class TestRecommendedAppServiceTrialFeatures: monkeypatch.setattr( service_module.FeatureService, "get_system_features", - MagicMock(return_value=SystemFeatureModel(enable_trial_app=True)), + MagicMock( + return_value=SystemFeatureModel( + deployment_edition=DeploymentEdition.COMMUNITY, + enable_trial_app=True, + ) + ), ) result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session) diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index bf2c32d5b1d..5fe6ab974e1 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -16,11 +16,21 @@ CHECK_UPDATE_URL=https://updates.dify.ai OPENAI_API_BASE=https://api.openai.com/v1 MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 +# System Features +MARKETPLACE_ENABLED=true +ENABLE_EMAIL_CODE_LOGIN=false +ENABLE_EMAIL_PASSWORD_LOGIN=true +ENABLE_SOCIAL_OAUTH_LOGIN=false # Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. ENABLE_COLLABORATION_MODE=true - -# Learn app feature toggle +ALLOW_REGISTER=false +ALLOW_CREATE_WORKSPACE=false +ENABLE_CHANGE_EMAIL=true +ENABLE_TRIAL_APP=false +ENABLE_EXPLORE_BANNER=false ENABLE_LEARN_APP=true +ENABLE_STEP_BY_STEP_TOUR=false +RBAC_ENABLED=false CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_TASK_ANNOTATIONS=null AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net @@ -90,6 +100,8 @@ WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= EXPOSE_PLUGIN_DEBUGGING_HOST=localhost EXPOSE_PLUGIN_DEBUGGING_PORT=5003 DEPLOY_ENV=PRODUCTION +EDITION=SELF_HOSTED +ENTERPRISE_ENABLED=false ACCESS_TOKEN_EXPIRE_MINUTES=60 REFRESH_TOKEN_EXPIRE_DAYS=30 APP_DEFAULT_ACTIVE_REQUESTS=0 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index 576e1673bc9..5c952f4af4c 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -15,7 +15,6 @@ TEXT_GENERATION_TIMEOUT_MS=60000 ALLOW_INLINE_STYLES=false ALLOW_UNSAFE_DATA_SCHEME=false MAX_TREE_DEPTH=50 -MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 ALLOW_EMBED=false diff --git a/lint.config.ts b/lint.config.ts index 34c6182d80e..6ee726761a9 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -660,7 +660,7 @@ export const lintConfig = { 'error', { allowConstantExport: true, - allowExportNames: [], + allowExportNames: ['viewport'], }, ], }, diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index fb2c46efaf5..0569de71067 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -5951,11 +5951,6 @@ "count": 1 } }, - "web/app/layout.tsx": { - "react/only-export-components": { - "count": 1 - } - }, "web/app/reset-password/check-code/page.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 diff --git a/packages/contracts/generated/api/console/system-features/orpc.gen.ts b/packages/contracts/generated/api/console/system-features/orpc.gen.ts index aa1e3a88948..d9ebc5df2b4 100644 --- a/packages/contracts/generated/api/console/system-features/orpc.gen.ts +++ b/packages/contracts/generated/api/console/system-features/orpc.gen.ts @@ -1,7 +1,31 @@ // This file is auto-generated by @hey-api/openapi-ts import { oc } from '@orpc/contract' -import { zGetSystemFeaturesResponse } from './zod.gen' +import { zGetSystemFeaturesLicenseResponse, zGetSystemFeaturesResponse } from './zod.gen' + +/** + * Get full license detail (status, expiry, workspace/seat usage) + * + * Get license status and usage detail + * Authenticated counterpart to the license *status* exposed on the public + * system-features endpoint. + */ +export const get = oc + .route({ + description: + 'Get license status and usage detail\nAuthenticated counterpart to the license *status* exposed on the public\nsystem-features endpoint.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSystemFeaturesLicense', + path: '/system-features/license', + summary: 'Get full license detail (status, expiry, workspace/seat usage)', + tags: ['console'], + }) + .output(zGetSystemFeaturesLicenseResponse) + +export const license = { + get, +} /** * Get system-wide feature configuration @@ -12,12 +36,13 @@ import { zGetSystemFeaturesResponse } from './zod.gen' * * Authentication would create circular dependency (can't login without dashboard loading). * - * Only non-sensitive configuration data should be returned by this endpoint. + * Only non-sensitive configuration data should be returned by this endpoint. Authenticated + * license detail is served separately by SystemFeatureLicenseApi. */ -export const get = oc +export const get2 = oc .route({ description: - "Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.", + "Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint. Authenticated\nlicense detail is served separately by SystemFeatureLicenseApi.", inputStructure: 'detailed', method: 'GET', operationId: 'getSystemFeatures', @@ -28,7 +53,8 @@ export const get = oc .output(zGetSystemFeaturesResponse) export const systemFeatures = { - get, + get: get2, + license, } export const contract = { diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 65652a60498..2f865c06736 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -6,6 +6,7 @@ export type ClientOptions = { export type SystemFeatureModel = { branding: BrandingModel + deployment_edition: DeploymentEdition enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean @@ -22,7 +23,7 @@ export type SystemFeatureModel = { is_allow_register: boolean is_email_setup: boolean knowledge_fs_enabled: boolean - license: LicenseModel + license: LicenseStatusModel max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel @@ -32,6 +33,13 @@ export type SystemFeatureModel = { webapp_auth: WebAppAuthModel } +export type LicenseModel = { + expired_at: string + seats: LicenseLimitationModel + status: LicenseStatus + workspaces: LicenseLimitationModel +} + export type BrandingModel = { application_title: string enabled: boolean @@ -40,11 +48,10 @@ export type BrandingModel = { workspace_logo: string } -export type LicenseModel = { - expired_at: string - seats: LicenseLimitationModel +export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' + +export type LicenseStatusModel = { status: LicenseStatus - workspaces: LicenseLimitationModel } export type PluginInstallationPermissionModel = { @@ -95,3 +102,17 @@ export type GetSystemFeaturesResponses = { } export type GetSystemFeaturesResponse = GetSystemFeaturesResponses[keyof GetSystemFeaturesResponses] + +export type GetSystemFeaturesLicenseData = { + body?: never + path?: never + query?: never + url: '/system-features/license' +} + +export type GetSystemFeaturesLicenseResponses = { + 200: LicenseModel +} + +export type GetSystemFeaturesLicenseResponse = + GetSystemFeaturesLicenseResponses[keyof GetSystemFeaturesLicenseResponses] diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 8d2aa34ee22..1ef07bee0bd 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -13,6 +13,13 @@ export const zBrandingModel = z.object({ workspace_logo: z.string().default(''), }) +/** + * DeploymentEdition + * + * Enum representing the deployment edition of the platform. + */ +export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE']) + /** * PluginManagerModel */ @@ -56,6 +63,13 @@ export const zLicenseModel = z.object({ }), }) +/** + * LicenseStatusModel + */ +export const zLicenseStatusModel = z.object({ + status: zLicenseStatus.default('none'), +}) + /** * PluginInstallationScope */ @@ -104,6 +118,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + deployment_edition: zDeploymentEdition, enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), @@ -120,20 +135,7 @@ export const zSystemFeatureModel = z.object({ is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), knowledge_fs_enabled: z.boolean().default(false), - license: zLicenseModel.default({ - expired_at: '', - seats: { - enabled: false, - limit: 0, - size: 0, - }, - status: 'none', - workspaces: { - enabled: false, - limit: 0, - size: 0, - }, - }), + license: zLicenseStatusModel.default({ status: 'none' }), max_plugin_package_size: z.int().default(15728640), plugin_installation_permission: zPluginInstallationPermissionModel.default({ plugin_installation_scope: 'all', @@ -157,3 +159,8 @@ export const zSystemFeatureModel = z.object({ * Success */ export const zGetSystemFeaturesResponse = zSystemFeatureModel + +/** + * Success + */ +export const zGetSystemFeaturesLicenseResponse = zLicenseModel diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index a593faced5c..3425dc64e5d 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -118,6 +118,8 @@ export type ConversationRenamePayload = ( name?: string | null } +export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' + export type EmailCodeLoginSendPayload = { email: string language?: string | null @@ -300,21 +302,12 @@ export type JsonValueType = unknown export type JsonValue2 = unknown -export type LicenseLimitationModel = { - enabled: boolean - limit: number - size: number -} - -export type LicenseModel = { - expired_at: string - seats: LicenseLimitationModel - status: LicenseStatus - workspaces: LicenseLimitationModel -} - export type LicenseStatus = 'active' | 'expired' | 'expiring' | 'inactive' | 'lost' | 'none' +export type LicenseStatusModel = { + status: LicenseStatus +} + export type LoginPayload = { email: string password: string @@ -510,6 +503,7 @@ export type SuggestedQuestionsResponse = { export type SystemFeatureModel = { branding: BrandingModel + deployment_edition: DeploymentEdition enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean @@ -526,7 +520,7 @@ export type SystemFeatureModel = { is_allow_register: boolean is_email_setup: boolean knowledge_fs_enabled: boolean - license: LicenseModel + license: LicenseStatusModel max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index b1504220f27..515245a69e3 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -132,6 +132,13 @@ export const zConversationRenamePayload = z.intersection( }), ) +/** + * DeploymentEdition + * + * Enum representing the deployment edition of the platform. + */ +export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE']) + /** * EmailCodeLoginSendPayload */ @@ -340,40 +347,16 @@ export const zHumanInputFormSubmitPayload = z.object({ inputs: z.record(z.string(), zJsonValue2), }) -/** - * LicenseLimitationModel - * - * - enabled: whether this limit is enforced - * - size: current usage count - * - limit: maximum allowed count; 0 means unlimited - */ -export const zLicenseLimitationModel = z.object({ - enabled: z.boolean().default(false), - limit: z.int().default(0), - size: z.int().default(0), -}) - /** * LicenseStatus */ export const zLicenseStatus = z.enum(['active', 'expired', 'expiring', 'inactive', 'lost', 'none']) /** - * LicenseModel + * LicenseStatusModel */ -export const zLicenseModel = z.object({ - expired_at: z.string().default(''), - seats: zLicenseLimitationModel.default({ - enabled: false, - limit: 0, - size: 0, - }), +export const zLicenseStatusModel = z.object({ status: zLicenseStatus.default('none'), - workspaces: zLicenseLimitationModel.default({ - enabled: false, - limit: 0, - size: 0, - }), }) /** @@ -782,6 +765,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + deployment_edition: zDeploymentEdition, enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), @@ -798,20 +782,7 @@ export const zSystemFeatureModel = z.object({ is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), knowledge_fs_enabled: z.boolean().default(false), - license: zLicenseModel.default({ - expired_at: '', - seats: { - enabled: false, - limit: 0, - size: 0, - }, - status: 'none', - workspaces: { - enabled: false, - limit: 0, - size: 0, - }, - }), + license: zLicenseStatusModel.default({ status: 'none' }), max_plugin_package_size: z.int().default(15728640), plugin_installation_permission: zPluginInstallationPermissionModel.default({ plugin_installation_scope: 'all', diff --git a/web/.env.example b/web/.env.example index 64b8082bbfd..1c8ed103949 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,9 +1,5 @@ # For production release, change this to PRODUCTION NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT -# The deployment edition, SELF_HOSTED -NEXT_PUBLIC_EDITION=SELF_HOSTED -# Whether a self-hosted deployment runs Enterprise Edition -NEXT_PUBLIC_ENTERPRISE_ENABLED=false # The base path for the application NEXT_PUBLIC_BASE_PATH= # Server-only console API origin for server-side requests. @@ -114,20 +110,3 @@ NEXT_PUBLIC_WEB_PREFIX= # number of concurrency NEXT_PUBLIC_BATCH_CONCURRENCY=5 - -# Cloud system-features frontend defaults. -# These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION). -NEXT_PUBLIC_ENABLE_MARKETPLACE=true -NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN=true -NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN=false -NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN=true -NEXT_PUBLIC_ENABLE_COLLABORATION_MODE=false -NEXT_PUBLIC_ALLOW_REGISTER=true -NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE=true -NEXT_PUBLIC_IS_EMAIL_SETUP=true -NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=true -NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=true -NEXT_PUBLIC_ENABLE_TRIAL_APP=true -NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=true -NEXT_PUBLIC_RBAC_ENABLED=false -NEXT_PUBLIC_KNOWLEDGE_FS_ENABLED=false diff --git a/web/Dockerfile b/web/Dockerfile index 72b2a50536b..d9e01580401 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -49,7 +49,6 @@ RUN pnpm build && pnpm build:vinext FROM base AS production ENV NODE_ENV=production -ENV EDITION=SELF_HOSTED ENV DEPLOY_ENV=PRODUCTION ENV CONSOLE_API_URL=http://127.0.0.1:5001 ENV APP_API_URL=http://127.0.0.1:5001 diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index d43b873a62a..8f3e21209fc 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -1,3 +1,5 @@ +import type { RenderOptions } from '@testing-library/react' +import type { ReactElement } from 'react' import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -14,21 +16,21 @@ import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-lim import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import VectorSpaceFull from '@/app/components/billing/vector-space-full' -import { render } from '@/test/console/render' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { render as renderWithConsoleState } from '@/test/console/render' + +const render = (ui: ReactElement, options: RenderOptions = {}) => { + const { wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + return renderWithConsoleState(ui, { ...options, wrapper }) +} let mockProviderCtx: Record = {} let mockConsoleState: Record = {} const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - IS_CLOUD_EDITION: true, - } -}) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockProviderCtx, })) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 1203cc106ae..6cbea5318fc 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -1,3 +1,5 @@ +import type { RenderOptions } from '@testing-library/react' +import type { ReactElement } from 'react' /** * Integration test: Education Verification Flow * @@ -14,7 +16,15 @@ import * as React from 'react' import { defaultPlan } from '@/app/components/billing/config' import PlanComp from '@/app/components/billing/plan' import { Plan } from '@/app/components/billing/type' -import { render } from '@/test/console/render' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { render as renderWithConsoleState } from '@/test/console/render' + +const render = (ui: ReactElement, options: RenderOptions = {}) => { + const { wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + return renderWithConsoleState(ui, { ...options, wrapper }) +} // ─── Mock state ────────────────────────────────────────────────────────────── let mockProviderCtx: Record = {} @@ -25,14 +35,6 @@ const mockRouterPush = vi.fn() const mockMutateAsync = vi.fn() const mockSetEducationVerifying = vi.hoisted(() => vi.fn()) -vi.mock('@/config', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - IS_CLOUD_EDITION: true, - } -}) - // ─── Context mocks ─────────────────────────────────────────────────────────── vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockProviderCtx, diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx index 95e2a5748a2..d5f89ee6859 100644 --- a/web/__tests__/billing/partner-stack-flow.test.tsx +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -7,11 +7,15 @@ * Covers URL param reading, cookie persistence, API bind on mount, * cookie cleanup after successful bind, and error handling for 400 status. */ -import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import Cookies from 'js-cookie' import * as React from 'react' import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info' import { PARTNER_STACK_CONFIG } from '@/config' +import { renderWithConsoleQuery } from '@/test/console/query-data' + +const render = (ui: React.ReactElement) => + renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } }) // ─── Mock state ────────────────────────────────────────────────────────────── let mockSearchParams = new URLSearchParams() @@ -39,7 +43,6 @@ vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal>() return { ...actual, - IS_CLOUD_EDITION: true, PARTNER_STACK_CONFIG: { cookieName: 'partner_stack_info', saveCookieDays: 90, @@ -288,7 +291,7 @@ describe('Partner Stack Flow', () => { // ─── 4. PartnerStack Component Mount ──────────────────────────────────── describe('PartnerStack component mount behavior', () => { - it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => { + it('should call saveOrUpdate and bind on mount', async () => { mockSearchParams = new URLSearchParams({ ps_partner_key: 'mount-partner', ps_xid: 'mount-click', @@ -299,18 +302,13 @@ describe('Partner Stack Flow', () => { render() - // The component calls saveOrUpdate and bind in useEffect await waitFor(() => { - // Bind should have been called expect(mockMutateAsync).toHaveBeenCalledWith({ partnerKey: 'mount-partner', clickId: 'mount-click', }) + expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() }) - - // Cookie should have been saved (saveOrUpdate was called before bind) - // After bind succeeds, cookie is removed - expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined() }) it('should render nothing (return null)', async () => { diff --git a/web/__tests__/env.spec.ts b/web/__tests__/env.spec.ts index 3a9f258a123..e2dcc0c2943 100644 --- a/web/__tests__/env.spec.ts +++ b/web/__tests__/env.spec.ts @@ -1,6 +1,5 @@ describe('env runtime transport', () => { const originalAgentV2Env = process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 - const originalRbacEnv = process.env.NEXT_PUBLIC_RBAC_ENABLED beforeEach(() => { vi.clearAllMocks() @@ -8,17 +7,12 @@ describe('env runtime transport', () => { vi.doUnmock('../utils/client') document.body.removeAttribute('data-enable-agent-v2') document.body.removeAttribute('data-enable-agent-v-2') - document.body.removeAttribute('data-rbac-enabled') delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 - delete process.env.NEXT_PUBLIC_RBAC_ENABLED }) afterAll(() => { if (originalAgentV2Env === undefined) delete process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 else process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = originalAgentV2Env - - if (originalRbacEnv === undefined) delete process.env.NEXT_PUBLIC_RBAC_ENABLED - else process.env.NEXT_PUBLIC_RBAC_ENABLED = originalRbacEnv }) it('should read NEXT_PUBLIC_ENABLE_AGENT_V2 from the browser runtime dataset key', async () => { @@ -29,14 +23,6 @@ describe('env runtime transport', () => { expect(env.NEXT_PUBLIC_ENABLE_AGENT_V2).toBe(true) }) - it('should read NEXT_PUBLIC_RBAC_ENABLED from the browser runtime dataset key', async () => { - document.body.setAttribute('data-rbac-enabled', 'true') - - const { env } = await import('../env') - - expect(env.NEXT_PUBLIC_RBAC_ENABLED).toBe(true) - }) - it('should emit the Agent v2 runtime dataset attribute from getDatasetMap on the server', async () => { process.env.NEXT_PUBLIC_ENABLE_AGENT_V2 = 'true' @@ -51,18 +37,4 @@ describe('env runtime transport', () => { expect(datasetMap['data-enable-agent-v2']).toBe(true) expect(datasetMap['data-enable-agent-v-2']).toBeUndefined() }) - - it('should emit the RBAC runtime dataset attribute from getDatasetMap on the server', async () => { - process.env.NEXT_PUBLIC_RBAC_ENABLED = 'true' - - vi.doMock('../utils/client', () => ({ - isClient: false, - isServer: true, - })) - - const { getDatasetMap } = await import('../env') - const datasetMap = getDatasetMap() - - expect(datasetMap['data-rbac-enabled']).toBe(true) - }) }) diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx index b34faae8248..13471dd863b 100644 --- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx +++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx @@ -1,12 +1,12 @@ +import type { DehydratedState } from '@tanstack/react-query' import type { ReactElement } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - queryClient: undefined as QueryClient | undefined, + rootQueryClient: undefined as QueryClient | undefined, profileQueryFn: vi.fn(), - systemFeaturesQueryFn: vi.fn(), workspaceQueryFn: vi.fn(), workspaceQueryOptions: vi.fn(), getServerConsoleClientContext: vi.fn(), @@ -18,9 +18,14 @@ const mocks = vi.hoisted(() => ({ basePath: '', })) -vi.mock('@/context/query-client-server', () => ({ - getQueryClientServer: () => mocks.queryClient, -})) +vi.mock('@/context/query-client-server', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + getQueryClientServer: () => mocks.rootQueryClient, + } +}) vi.mock('@/next/headers', () => ({ headers: () => mocks.headers(), @@ -58,19 +63,11 @@ vi.mock('@/service/server', () => ({ }, })) -vi.mock('@/features/system-features/server', () => ({ - serverSystemFeaturesQueryOptions: () => ({ - queryKey: ['console', 'system-features'], - queryFn: mocks.systemFeaturesQueryFn, - retry: false, - }), -})) - describe('CommonLayoutHydrationBoundary', () => { beforeEach(() => { vi.clearAllMocks() mocks.basePath = '' - mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mocks.rootQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) mocks.headers.mockResolvedValue( new Headers({ 'x-dify-pathname': '/apps', @@ -94,7 +91,6 @@ describe('CommonLayoutHydrationBoundary', () => { currentEnv: 'DEVELOPMENT', }, }) - mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } }) mocks.workspaceQueryFn.mockResolvedValue({ id: 'workspace-id', name: 'Workspace' }) mocks.getServerConsoleClientContext.mockResolvedValue({ cookie: 'session=abc', @@ -107,7 +103,7 @@ describe('CommonLayoutHydrationBoundary', () => { }) }) - it('should prefetch common layout queries and render children', async () => { + it('should prefetch common layout queries', async () => { const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') const element = await CommonLayoutHydrationBoundary({ @@ -121,7 +117,6 @@ describe('CommonLayoutHydrationBoundary', () => { ) expect(screen.getByText('Common shell')).toBeInTheDocument() expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1) - expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1) expect(mocks.getServerConsoleClientContext).toHaveBeenCalledTimes(1) expect(mocks.workspaceQueryOptions).toHaveBeenCalledWith({ context: { @@ -133,6 +128,25 @@ describe('CommonLayoutHydrationBoundary', () => { expect(mocks.workspaceQueryFn).toHaveBeenCalledTimes(1) }) + it('should dehydrate only Common-owned queries', async () => { + mocks.rootQueryClient?.setQueryData(['console', 'system-features'], { + deployment_edition: 'CLOUD', + }) + const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary') + + const element = await CommonLayoutHydrationBoundary({ children: null }) + const state = (element as ReactElement<{ state: DehydratedState }>).props.state + const queryKeys = state.queries.map((query) => query.queryKey) + + expect(queryKeys).toHaveLength(2) + expect(queryKeys).toEqual( + expect.arrayContaining([ + ['common', 'user-profile'], + ['console', 'workspaces', 'current', 'post'], + ]), + ) + }) + it('should redirect unauthorized users to the refresh route with the current path', async () => { mocks.basePath = '/workflow' mocks.profileQueryFn.mockRejectedValue( @@ -188,7 +202,6 @@ describe('CommonLayoutHydrationBoundary', () => { ) expect(screen.getByText('Common shell')).toBeInTheDocument() expect(mocks.profileQueryFn).not.toHaveBeenCalled() - expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled() expect(mocks.workspaceQueryFn).not.toHaveBeenCalled() }) }) diff --git a/web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx b/web/app/(commonLayout)/__tests__/profile-bootstrap-gate.spec.tsx similarity index 68% rename from web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx rename to web/app/(commonLayout)/__tests__/profile-bootstrap-gate.spec.tsx index 7656aac6d18..ca33736de85 100644 --- a/web/app/(commonLayout)/__tests__/console-bootstrap-gate.spec.tsx +++ b/web/app/(commonLayout)/__tests__/profile-bootstrap-gate.spec.tsx @@ -2,10 +2,9 @@ import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ConsoleBootstrapGate } from '../console-bootstrap-gate' +import { ProfileBootstrapGate } from '../profile-bootstrap-gate' const profileQueryKey = ['console', 'account', 'profile', 'get'] -const systemFeaturesQueryKey = ['console', 'system-features', 'get'] type Deferred = { promise: Promise @@ -13,7 +12,7 @@ type Deferred = { reject: (reason?: unknown) => void } -const createDeferred = (): Deferred => { +function createDeferred(): Deferred { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((nextResolve, nextReject) => { @@ -26,7 +25,6 @@ const createDeferred = (): Deferred => { const mocks = vi.hoisted(() => ({ profileQuery: undefined as Deferred<{ id: string }> | undefined, - systemFeaturesQuery: undefined as Deferred<{ branding: { enabled: boolean } }> | undefined, })) vi.mock('@/features/account-profile/client', () => ({ @@ -36,13 +34,6 @@ vi.mock('@/features/account-profile/client', () => ({ }), })) -vi.mock('@/features/system-features/client', () => ({ - systemFeaturesQueryOptions: () => ({ - queryKey: systemFeaturesQueryKey, - queryFn: () => mocks.systemFeaturesQuery!.promise, - }), -})) - function createQueryClient() { return new QueryClient({ defaultOptions: { @@ -54,20 +45,19 @@ function createQueryClient() { function renderGate(children: ReactNode, queryClient = createQueryClient()) { render( - {children} + {children} , ) return queryClient } -describe('ConsoleBootstrapGate', () => { +describe('ProfileBootstrapGate', () => { beforeEach(() => { mocks.profileQuery = createDeferred() - mocks.systemFeaturesQuery = createDeferred() }) - it('waits for profile and system features before mounting atom consumers', async () => { + it('waits for the profile before mounting atom consumers', async () => { renderGate(
Console shell
) expect(screen.queryByText('Console shell')).not.toBeInTheDocument() @@ -75,11 +65,6 @@ describe('ConsoleBootstrapGate', () => { await act(async () => { mocks.profileQuery!.resolve({ id: 'user-1' }) }) - expect(screen.queryByText('Console shell')).not.toBeInTheDocument() - - await act(async () => { - mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } }) - }) expect(await screen.findByText('Console shell')).toBeInTheDocument() }) @@ -87,11 +72,6 @@ describe('ConsoleBootstrapGate', () => { it('keeps atom consumers mounted when a cached profile background refetch fails', async () => { const queryClient = createQueryClient() queryClient.setQueryData(profileQueryKey, { id: 'user-1' }, { updatedAt: 1 }) - queryClient.setQueryData( - systemFeaturesQueryKey, - { branding: { enabled: false } }, - { updatedAt: 1 }, - ) renderGate(
Console shell
, queryClient) @@ -102,7 +82,6 @@ describe('ConsoleBootstrapGate', () => { await act(async () => { mocks.profileQuery!.reject(new Error('profile refetch failed')) - mocks.systemFeaturesQuery!.resolve({ branding: { enabled: false } }) }) await waitFor(() => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx index 89102a1386a..3b82a635dd3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx @@ -1,5 +1,6 @@ +import type { PeriodParams } from '@/app/components/app/overview/app-chart' import { screen } from '@testing-library/react' -import { renderWithAccountProfile as render } from '@/test/console/account-profile' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AppACLPermission } from '@/utils/permission' import ChartView from '../chart-view' @@ -13,6 +14,7 @@ const testState = vi.hoisted(() => ({ currentUserId: 'user-1', workspacePermissionKeys: [] as string[], chartRenderSpy: vi.fn(), + conversationPeriodSpy: vi.fn(), })) vi.mock('@/context/workspace-state', async () => { @@ -46,8 +48,9 @@ vi.mock('@/app/components/app/overview/app-chart', () => ({ testState.chartRenderSpy('avg-user-interactions') return
avg user interactions chart
}, - ConversationsChart: () => { + ConversationsChart: ({ period }: { period: PeriodParams }) => { testState.chartRenderSpy('conversations') + testState.conversationPeriodSpy(period) return
conversations chart
}, CostChart: () => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index e55345a8287..243a2a931ec 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -1,6 +1,8 @@ 'use client' +import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' import type { PeriodParams } from '@/app/components/app/overview/app-chart' import type { I18nKeysByPrefix } from '@/types/i18n' +import { useSuspenseQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import quarterOfYear from 'dayjs/plugin/quarterOfYear' import { useAtomValue } from 'jotai' @@ -23,10 +25,10 @@ import { WorkflowMessagesChart, } from '@/app/components/app/overview/app-chart' import { useStore as useAppStore } from '@/app/components/app/store' -import { IS_CLOUD_EDITION } from '@/config' import { userProfileIdAtom } from '@/context/account-state' import { useDocLink } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { getAppACLCapabilities } from '@/utils/permission' import LongTimeRangePicker from './long-time-range-picker' import TimeRangePicker from './time-range-picker' @@ -51,7 +53,28 @@ type IChartViewProps = { } export default function ChartView({ appId, headerRight }: IChartViewProps) { + const { data: deploymentEdition } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: ({ deployment_edition }) => deployment_edition, + }) + + return ( + + ) +} + +function ChartViewContent({ + appId, + headerRight, + deploymentEdition, +}: IChartViewProps & { deploymentEdition: DeploymentEdition }) { const { t } = useTranslation() + const isCloudEdition = deploymentEdition === 'CLOUD' + const isNonCloudEdition = deploymentEdition === 'COMMUNITY' || deploymentEdition === 'ENTERPRISE' const docLink = useDocLink() const appDetail = useAppStore((state) => state.appDetail) const currentUserId = useAtomValue(userProfileIdAtom) @@ -67,8 +90,8 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { ) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' - const [period, setPeriod] = useState( - IS_CLOUD_EDITION + const [period, setPeriod] = useState(() => + isCloudEdition ? { name: t(($) => $['filter.period.today'], { ns: 'appLog' }), query: { @@ -112,13 +135,14 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) {
- {IS_CLOUD_EDITION ? ( + {isCloudEdition && ( - ) : ( + )} + {isNonCloudEdition && ( { workspacePermissionKeys: [], })) }) -vi.mock('@/context/system-features-state', async () => { - const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture') - - return createSystemFeaturesStateModuleMock(() => ({ - datasetRbacEnabled: mockIsRbacEnabled, - })) -}) - vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: undefined, diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 86e90b3aa68..bffbf673d54 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect } from 'react' @@ -13,8 +14,8 @@ import { workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom, } from '@/context/permission-state' -import { datasetRbacEnabledAtom } from '@/context/system-features-state' import { currentWorkspaceLoadingAtom } from '@/context/workspace-state' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { useDatasetDetail } from '@/service/knowledge/use-dataset' @@ -60,7 +61,10 @@ const DatasetDetailLayout: FC = (props) => { const pathname = usePathname() const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) - const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom) + const { data: isRbacEnabled } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: ({ rbac_enabled }) => rbac_enabled, + }) const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) diff --git a/web/app/(commonLayout)/datasets/new/[knowledgeSpaceId]/layout.tsx b/web/app/(commonLayout)/datasets/new/[knowledgeSpaceId]/layout.tsx index 2d2499e4783..aefbec8e6ce 100644 --- a/web/app/(commonLayout)/datasets/new/[knowledgeSpaceId]/layout.tsx +++ b/web/app/(commonLayout)/datasets/new/[knowledgeSpaceId]/layout.tsx @@ -1,4 +1,3 @@ -import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard' import { KnowledgeSpaceShell } from '@/features/new-rag/knowledge-space-shell' export default async function Layout({ @@ -10,9 +9,5 @@ export default async function Layout({ }) { const { knowledgeSpaceId } = await params - return ( - - {children} - - ) + return {children} } diff --git a/web/app/(commonLayout)/datasets/new/__tests__/layout.spec.tsx b/web/app/(commonLayout)/datasets/new/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..256cd2a1c7b --- /dev/null +++ b/web/app/(commonLayout)/datasets/new/__tests__/layout.spec.tsx @@ -0,0 +1,71 @@ +import type { ReactElement, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' + +const mocks = vi.hoisted(() => ({ + ensureQueryData: vi.fn(), + redirect: vi.fn((_href: string) => { + throw new Error('NEXT_REDIRECT') + }), + systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] }, +})) + +vi.mock('@/context/query-client-server', () => ({ + getQueryClientServer: () => ({ + ensureQueryData: mocks.ensureQueryData, + }), +})) + +vi.mock('@/service/server', () => ({ + serverConsoleQuery: { + systemFeatures: { + get: { + queryOptions: () => mocks.systemFeaturesQueryOptions, + }, + }, + }, +})) + +vi.mock('@/next/navigation', () => ({ + redirect: (href: string) => mocks.redirect(href), +})) + +async function renderLayout(children: ReactNode) { + const { default: Layout } = await import('../layout') + const element = await Layout({ children }) + render(element as ReactElement) +} + +describe('NewKnowledgeLayout', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureQueryData.mockResolvedValue({ knowledge_fs_enabled: true }) + }) + + it('renders new knowledge routes when KnowledgeFS is enabled', async () => { + await renderLayout(
New knowledge content
) + + expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions) + expect(screen.getByText('New knowledge content')).toBeInTheDocument() + expect(mocks.redirect).not.toHaveBeenCalled() + }) + + it('redirects to datasets when KnowledgeFS is disabled', async () => { + mocks.ensureQueryData.mockResolvedValue({ knowledge_fs_enabled: false }) + const { default: Layout } = await import('../layout') + + await expect(Layout({ children:
New knowledge content
})).rejects.toThrow( + 'NEXT_REDIRECT', + ) + + expect(mocks.redirect).toHaveBeenCalledWith('/datasets') + }) + + it('preserves System Features failures', async () => { + const error = new Error('System Features unavailable') + mocks.ensureQueryData.mockRejectedValue(error) + const { default: Layout } = await import('../layout') + + await expect(Layout({ children:
New knowledge content
})).rejects.toBe(error) + expect(mocks.redirect).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/(commonLayout)/datasets/new/create/page.tsx b/web/app/(commonLayout)/datasets/new/create/page.tsx index 200effeb522..9daec78165d 100644 --- a/web/app/(commonLayout)/datasets/new/create/page.tsx +++ b/web/app/(commonLayout)/datasets/new/create/page.tsx @@ -1,10 +1,5 @@ import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page' -import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard' export default function Page() { - return ( - - - - ) + return } diff --git a/web/app/(commonLayout)/datasets/new/layout.tsx b/web/app/(commonLayout)/datasets/new/layout.tsx new file mode 100644 index 00000000000..9df5dcadd6c --- /dev/null +++ b/web/app/(commonLayout)/datasets/new/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' +import { getQueryClientServer } from '@/context/query-client-server' +import { redirect } from '@/next/navigation' +import { serverConsoleQuery } from '@/service/server' + +export default async function Layout({ children }: { children: ReactNode }) { + const systemFeatures = await getQueryClientServer().ensureQueryData( + serverConsoleQuery.systemFeatures.get.queryOptions(), + ) + + if (!systemFeatures.knowledge_fs_enabled) redirect('/datasets') + + return children +} diff --git a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx index fbf32ce0f98..d609f167574 100644 --- a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx @@ -15,8 +15,14 @@ vi.mock('@/context/query-client-server', () => ({ }), })) -vi.mock('@/features/system-features/server', () => ({ - serverSystemFeaturesQueryOptions: () => mocks.systemFeaturesQueryOptions, +vi.mock('@/service/server', () => ({ + serverConsoleQuery: { + systemFeatures: { + get: { + queryOptions: () => mocks.systemFeaturesQueryOptions, + }, + }, + }, })) vi.mock('@/features/deployments/deploy-drawer', () => ({ diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index c9b2c5fedf0..e5f8a29756e 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from 'react' import { getQueryClientServer } from '@/context/query-client-server' import { DeployDrawer } from '@/features/deployments/deploy-drawer' -import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server' import { notFound } from '@/next/navigation' +import { serverConsoleQuery } from '@/service/server' export default async function DeploymentsLayout({ children }: { children: ReactNode }) { const systemFeatures = await getQueryClientServer().ensureQueryData( - serverSystemFeaturesQueryOptions(), + serverConsoleQuery.systemFeatures.get.queryOptions(), ) if (!systemFeatures.enable_app_deploy) notFound() diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx index c396b6968eb..26400fc1fb9 100644 --- a/web/app/(commonLayout)/error.tsx +++ b/web/app/(commonLayout)/error.tsx @@ -1,6 +1,7 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { useQueryErrorResetBoundary } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { FullScreenLoading } from '@/app/components/full-screen-loading' import { isLegacyBase401 } from '@/features/account-profile/client' @@ -12,6 +13,7 @@ type Props = Readonly<{ export default function CommonLayoutError({ error, unstable_retry }: Props) { const { t } = useTranslation('common') + const { reset } = useQueryErrorResetBoundary() console.error(error) @@ -26,7 +28,14 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
{t(($) => $['errorBoundary.message'])}
-
diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx index dda23f3e43f..6207780f9fb 100644 --- a/web/app/(commonLayout)/hydration-boundary.tsx +++ b/web/app/(commonLayout)/hydration-boundary.tsx @@ -1,8 +1,7 @@ import type { ReactNode } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' -import { getQueryClientServer } from '@/context/query-client-server' +import { makeQueryClient } from '@/context/query-client-server' import { serverUserProfileQueryOptions } from '@/features/account-profile/server' -import { serverSystemFeaturesQueryOptions } from '@/features/system-features/server' import { headers } from '@/next/headers' import { redirect } from '@/next/navigation' import { @@ -56,7 +55,7 @@ const handleProfileError = async (error: unknown) => { } export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) { - const queryClient = getQueryClientServer() + const queryClient = makeQueryClient() const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH) if (accountProfileUrl) { @@ -65,7 +64,6 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re await Promise.all([ queryClient.fetchQuery(serverUserProfileQueryOptions()), - queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()), queryClient.prefetchQuery( serverConsoleQuery.workspaces.current.post.queryOptions({ context, diff --git a/web/app/(commonLayout)/profile-bootstrap-gate.tsx b/web/app/(commonLayout)/profile-bootstrap-gate.tsx new file mode 100644 index 00000000000..35471dc635a --- /dev/null +++ b/web/app/(commonLayout)/profile-bootstrap-gate.tsx @@ -0,0 +1,11 @@ +'use client' + +import type { ReactNode } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' +import { userProfileQueryOptions } from '@/features/account-profile/client' + +export function ProfileBootstrapGate({ children }: { children: ReactNode }) { + useSuspenseQuery(userProfileQueryOptions()) + + return children +} diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index abd8b5a7d4f..28cd8ab94f5 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -4,9 +4,9 @@ import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration- import { EventEmitterContextProvider } from '@/context/event-emitter-provider' import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' -import { ConsoleBootstrapGate } from './console-bootstrap-gate' import { ExternalServiceSync } from './external-service-sync' import { CommonLayoutHydrationBoundary } from './hydration-boundary' +import { ProfileBootstrapGate } from './profile-bootstrap-gate' export async function ConsoleRuntimeProviders({ children }: { children: ReactNode }) { return ( @@ -14,10 +14,10 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod - + {children} - + ) diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 7def2fc6823..b1f3b35abe1 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -6,7 +6,6 @@ import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { IS_CE_EDITION } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { LicenseStatus } from '@/features/system-features/constants' import Link from '@/next/link' @@ -19,6 +18,9 @@ const NormalForm = () => { const [isLoading, setIsLoading] = useState(true) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const isNonCloudEdition = + systemFeatures.deployment_edition === 'COMMUNITY' || + systemFeatures.deployment_edition === 'ENTERPRISE' const [authType, updateAuthType] = useState<'code' | 'password'>('password') const [showORLine, setShowORLine] = useState(false) const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) @@ -236,7 +238,7 @@ const NormalForm = () => { {t(($) => $.pp, { ns: 'login' })} - {IS_CE_EDITION && ( + {isNonCloudEdition && (
{t(($) => $.goToInit, { ns: 'login' })}   diff --git a/web/app/__tests__/error.spec.tsx b/web/app/__tests__/error.spec.tsx new file mode 100644 index 00000000000..02f70aba22a --- /dev/null +++ b/web/app/__tests__/error.spec.tsx @@ -0,0 +1,39 @@ +import { QueryErrorResetBoundary } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CommonLayoutError from '@/app/(commonLayout)/error' +import AppError from '@/app/error' + +describe('route error recovery', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + { + name: 'root error', + renderError: (retry: () => void) => , + }, + { + name: 'common layout error', + renderError: (retry: () => void) => ( + + ), + }, + ])('resets failed queries before retrying the $name', async ({ renderError }) => { + const user = userEvent.setup() + const retry = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + + render( + + {({ isReset }) => renderError(() => retry(isReset()))} + , + ) + + await user.click(screen.getByRole('button', { name: 'common.errorBoundary.tryAgain' })) + + expect(retry).toHaveBeenCalledWith(true) + }) +}) diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..bfddd400f02 --- /dev/null +++ b/web/app/__tests__/layout.spec.tsx @@ -0,0 +1,71 @@ +import { QueryClient } from '@tanstack/react-query' + +let queryClient: QueryClient + +const mocks = vi.hoisted(() => ({ + getSystemFeatures: vi.fn(), + requestHeaders: new Headers(), +})) + +vi.mock('@/context/query-client-server', () => ({ + getQueryClientServer: () => queryClient, +})) + +vi.mock('@/env', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + getDatasetMap: () => ({}), + } +}) + +vi.mock('@/service/server', () => ({ + serverConsoleQuery: { + systemFeatures: { + get: { + queryOptions: () => ({ + queryKey: ['console', 'system-features'], + queryFn: mocks.getSystemFeatures, + retry: false, + }), + }, + }, + }, +})) + +vi.mock('@/i18n-config/server', () => ({ + getLocaleOnServer: async () => 'en-US', +})) + +vi.mock('@/next/headers', () => ({ + headers: async () => mocks.requestHeaders, +})) + +describe('Root layout System Features bootstrap', () => { + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + }) + + it('caches the resolved System Features for dehydration', async () => { + mocks.getSystemFeatures.mockResolvedValue({ deployment_edition: 'CLOUD' }) + const { default: RootLayout } = await import('../layout') + + await expect(RootLayout({ children:
App
})).resolves.toBeDefined() + + expect(mocks.getSystemFeatures).toHaveBeenCalledTimes(1) + expect(queryClient.getQueryData(['console', 'system-features'])).toEqual({ + deployment_edition: 'CLOUD', + }) + }) + + it('renders the client recovery path when the server prefetch fails', async () => { + mocks.getSystemFeatures.mockRejectedValue(new Error('system features unavailable')) + const { default: RootLayout } = await import('../layout') + + await expect(RootLayout({ children:
App
})).resolves.toBeDefined() + + expect(queryClient.getQueryData(['console', 'system-features'])).toBeUndefined() + }) +}) diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index 5415c56e5b7..219bdc47c03 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -12,7 +12,7 @@ import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' import Collapse from '@/app/components/header/account-setting/collapse' -import { IS_CE_EDITION, validPassword } from '@/config' +import { validPassword } from '@/config' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -243,7 +243,7 @@ export default function AccountPage() { wrapperClassName="mt-2" /> )} - {!IS_CE_EDITION && ( + {systemFeatures.deployment_edition === 'CLOUD' && (