From 7bf3d2c8bf5715305e1615fb4a5ba02c42d03643 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Wed, 16 Jul 2025 00:01:44 +0800 Subject: [PATCH 01/91] fix(api): Fix potential thread leak in MCP `BaseSession` (#22169) The `BaseSession` class in the `core/mcp/session` package uses `ThreadPoolExecutor` to run the receive loop but fails to properly clean up the executor and receiver future, leading to potential thread leaks. This PR addresses this issue by: - Initializing `_executor` and `_receiver_future` attributes to `None` for proper cleanup checks - Adding graceful shutdown with a 5-second timeout in the `__exit__` method - Ensuring the ThreadPoolExecutor is properly shut down to prevent resource leaks This fix prevents memory leaks and hanging threads in long-running scenarios where multiple MCP sessions are created and destroyed. Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> Co-authored-by: QuantumGhost Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/core/mcp/session/base_session.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/api/core/mcp/session/base_session.py b/api/core/mcp/session/base_session.py index 1c0f582501..7734b8fdd9 100644 --- a/api/core/mcp/session/base_session.py +++ b/api/core/mcp/session/base_session.py @@ -1,7 +1,7 @@ import logging import queue from collections.abc import Callable -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError from contextlib import ExitStack from datetime import timedelta from types import TracebackType @@ -171,23 +171,41 @@ class BaseSession( self._session_read_timeout_seconds = read_timeout_seconds self._in_flight = {} self._exit_stack = ExitStack() + # Initialize executor and future to None for proper cleanup checks + self._executor: ThreadPoolExecutor | None = None + self._receiver_future: Future | None = None def __enter__(self) -> Self: - self._executor = ThreadPoolExecutor() + # The thread pool is dedicated to running `_receive_loop`. Setting `max_workers` to 1 + # ensures no unnecessary threads are created. + self._executor = ThreadPoolExecutor(max_workers=1) self._receiver_future = self._executor.submit(self._receive_loop) return self def check_receiver_status(self) -> None: - if self._receiver_future.done(): + """`check_receiver_status` ensures that any exceptions raised during the + execution of `_receive_loop` are retrieved and propagated.""" + if self._receiver_future and self._receiver_future.done(): self._receiver_future.result() def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None ) -> None: - self._exit_stack.close() self._read_stream.put(None) self._write_stream.put(None) + # Wait for the receiver loop to finish + if self._receiver_future: + try: + self._receiver_future.result(timeout=5.0) # Wait up to 5 seconds + except TimeoutError: + # If the receiver loop is still running after timeout, we'll force shutdown + pass + + # Shutdown the executor + if self._executor: + self._executor.shutdown(wait=True) + def send_request( self, request: SendRequestT, From 1f4b3591ae258fa369992a8ce141e3283250904e Mon Sep 17 00:00:00 2001 From: znn Date: Wed, 16 Jul 2025 07:29:42 +0530 Subject: [PATCH 02/91] adding tooltip for bindingCount (#22450) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: crazywoola <427733928@qq.com> --- .../components/base/tag-management/tag-item-editor.tsx | 10 +++++++++- web/i18n/en-US/workflow.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/web/app/components/base/tag-management/tag-item-editor.tsx b/web/app/components/base/tag-management/tag-item-editor.tsx index 3264979955..0fdfc5079a 100644 --- a/web/app/components/base/tag-management/tag-item-editor.tsx +++ b/web/app/components/base/tag-management/tag-item-editor.tsx @@ -12,6 +12,7 @@ import Confirm from '@/app/components/base/confirm' import cn from '@/utils/classnames' import type { Tag } from '@/app/components/base/tag-management/constant' import { ToastContext } from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' import { deleteTag, updateTag, @@ -109,7 +110,14 @@ const TagItemEditor: FC = ({
{tag.name}
-
{tag.binding_count}
+ {t('workflow.common.tagBound')} + } + needsDelay + > +
{tag.binding_count}
+
setIsEditing(true)}>
diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c56b497ac2..763739ba32 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -113,6 +113,7 @@ const translation = { addFailureBranch: 'Add Fail Branch', loadMore: 'Load More', noHistory: 'No History', + tagBound: 'Number of apps using this tag', }, env: { envPanelTitle: 'Environment Variables', From 38106074b4214582af689be524b73eb5e246bb03 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:07:01 +0800 Subject: [PATCH 03/91] test: add comprehensive unit tests for console authentication and authorization decorators (#22439) --- .../controllers/console/test_wraps.py | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 api/tests/unit_tests/controllers/console/test_wraps.py diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py new file mode 100644 index 0000000000..9742368f04 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -0,0 +1,380 @@ +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from flask_login import LoginManager, UserMixin + +from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout +from controllers.console.workspace.error import AccountNotInitializedError +from controllers.console.wraps import ( + account_initialization_required, + cloud_edition_billing_rate_limit_check, + cloud_edition_billing_resource_check, + enterprise_license_required, + only_edition_cloud, + only_edition_enterprise, + only_edition_self_hosted, + setup_required, +) +from models.account import AccountStatus +from services.feature_service import LicenseStatus + + +class MockUser(UserMixin): + """Simple User class for testing.""" + + def __init__(self, user_id: str): + self.id = user_id + self.current_tenant_id = "tenant123" + + def get_id(self) -> str: + return self.id + + +def create_app_with_login(): + """Create a Flask app with LoginManager configured.""" + app = Flask(__name__) + app.config["SECRET_KEY"] = "test-secret-key" + + login_manager = LoginManager() + login_manager.init_app(app) + + @login_manager.user_loader + def load_user(user_id: str): + return MockUser(user_id) + + return app + + +class TestAccountInitialization: + """Test account initialization decorator""" + + def test_should_allow_initialized_account(self): + """Test that initialized accounts can access protected views""" + # Arrange + mock_user = MagicMock() + mock_user.status = AccountStatus.ACTIVE + + @account_initialization_required + def protected_view(): + return "success" + + # Act + with patch("controllers.console.wraps.current_user", mock_user): + result = protected_view() + + # Assert + assert result == "success" + + def test_should_reject_uninitialized_account(self): + """Test that uninitialized accounts raise AccountNotInitializedError""" + # Arrange + mock_user = MagicMock() + mock_user.status = AccountStatus.UNINITIALIZED + + @account_initialization_required + def protected_view(): + return "success" + + # Act & Assert + with patch("controllers.console.wraps.current_user", mock_user): + with pytest.raises(AccountNotInitializedError): + protected_view() + + +class TestEditionChecks: + """Test edition-specific decorators""" + + def test_only_edition_cloud_allows_cloud_edition(self): + """Test cloud edition decorator allows CLOUD edition""" + + # Arrange + @only_edition_cloud + def cloud_view(): + return "cloud_success" + + # Act + with patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"): + result = cloud_view() + + # Assert + assert result == "cloud_success" + + def test_only_edition_cloud_rejects_other_editions(self): + """Test cloud edition decorator rejects non-CLOUD editions""" + # Arrange + app = Flask(__name__) + + @only_edition_cloud + def cloud_view(): + return "cloud_success" + + # Act & Assert + with app.test_request_context(): + with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with pytest.raises(Exception) as exc_info: + cloud_view() + assert exc_info.value.code == 404 + + def test_only_edition_enterprise_allows_when_enabled(self): + """Test enterprise edition decorator allows when ENTERPRISE_ENABLED is True""" + + # Arrange + @only_edition_enterprise + def enterprise_view(): + return "enterprise_success" + + # Act + with patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True): + result = enterprise_view() + + # Assert + assert result == "enterprise_success" + + def test_only_edition_self_hosted_allows_self_hosted(self): + """Test self-hosted edition decorator allows SELF_HOSTED edition""" + + # Arrange + @only_edition_self_hosted + def self_hosted_view(): + return "self_hosted_success" + + # Act + with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + result = self_hosted_view() + + # Assert + assert result == "self_hosted_success" + + +class TestBillingResourceLimits: + """Test billing resource limit decorators""" + + def test_should_allow_when_under_resource_limit(self): + """Test that requests are allowed when under resource limits""" + # Arrange + mock_features = MagicMock() + mock_features.billing.enabled = True + mock_features.members.limit = 10 + mock_features.members.size = 5 + + @cloud_edition_billing_resource_check("members") + def add_member(): + return "member_added" + + # Act + with patch("controllers.console.wraps.current_user"): + with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + result = add_member() + + # Assert + assert result == "member_added" + + def test_should_reject_when_over_resource_limit(self): + """Test that requests are rejected when over resource limits""" + # Arrange + app = create_app_with_login() + mock_features = MagicMock() + mock_features.billing.enabled = True + mock_features.members.limit = 10 + mock_features.members.size = 10 + + @cloud_edition_billing_resource_check("members") + def add_member(): + return "member_added" + + # Act & Assert + with app.test_request_context(): + with patch("controllers.console.wraps.current_user", MockUser("test_user")): + with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + with pytest.raises(Exception) as exc_info: + add_member() + assert exc_info.value.code == 403 + assert "members has reached the limit" in str(exc_info.value.description) + + def test_should_check_source_for_documents_limit(self): + """Test document limit checks request source""" + # Arrange + app = create_app_with_login() + mock_features = MagicMock() + mock_features.billing.enabled = True + mock_features.documents_upload_quota.limit = 100 + mock_features.documents_upload_quota.size = 100 + + @cloud_edition_billing_resource_check("documents") + def upload_document(): + return "document_uploaded" + + # Test 1: Should reject when source is datasets + with app.test_request_context("/?source=datasets"): + with patch("controllers.console.wraps.current_user", MockUser("test_user")): + with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + with pytest.raises(Exception) as exc_info: + upload_document() + assert exc_info.value.code == 403 + + # Test 2: Should allow when source is not datasets + with app.test_request_context("/?source=other"): + with patch("controllers.console.wraps.current_user", MockUser("test_user")): + with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features): + result = upload_document() + assert result == "document_uploaded" + + +class TestRateLimiting: + """Test rate limiting decorator""" + + @patch("controllers.console.wraps.redis_client") + @patch("controllers.console.wraps.db") + def test_should_allow_requests_within_rate_limit(self, mock_db, mock_redis): + """Test that requests within rate limit are allowed""" + # Arrange + mock_rate_limit = MagicMock() + mock_rate_limit.enabled = True + mock_rate_limit.limit = 10 + mock_redis.zcard.return_value = 5 # 5 requests in window + + @cloud_edition_billing_rate_limit_check("knowledge") + def knowledge_request(): + return "knowledge_success" + + # Act + with patch("controllers.console.wraps.current_user"): + with patch( + "controllers.console.wraps.FeatureService.get_knowledge_rate_limit", return_value=mock_rate_limit + ): + result = knowledge_request() + + # Assert + assert result == "knowledge_success" + mock_redis.zadd.assert_called_once() + mock_redis.zremrangebyscore.assert_called_once() + + @patch("controllers.console.wraps.redis_client") + @patch("controllers.console.wraps.db") + def test_should_reject_requests_over_rate_limit(self, mock_db, mock_redis): + """Test that requests over rate limit are rejected and logged""" + # Arrange + app = create_app_with_login() + mock_rate_limit = MagicMock() + mock_rate_limit.enabled = True + mock_rate_limit.limit = 10 + mock_rate_limit.subscription_plan = "pro" + mock_redis.zcard.return_value = 11 # Over limit + + mock_session = MagicMock() + mock_db.session = mock_session + + @cloud_edition_billing_rate_limit_check("knowledge") + def knowledge_request(): + return "knowledge_success" + + # Act & Assert + with app.test_request_context(): + with patch("controllers.console.wraps.current_user", MockUser("test_user")): + with patch( + "controllers.console.wraps.FeatureService.get_knowledge_rate_limit", return_value=mock_rate_limit + ): + with pytest.raises(Exception) as exc_info: + knowledge_request() + + # Verify error + assert exc_info.value.code == 403 + assert "rate limit" in str(exc_info.value.description) + + # Verify rate limit log was created + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + +class TestSystemSetup: + """Test system setup decorator""" + + @patch("controllers.console.wraps.db") + def test_should_allow_when_setup_complete(self, mock_db): + """Test that requests are allowed when setup is complete""" + # Arrange + mock_db.session.query.return_value.first.return_value = MagicMock() # Setup exists + + @setup_required + def admin_view(): + return "admin_success" + + # Act + with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + result = admin_view() + + # Assert + assert result == "admin_success" + + @patch("controllers.console.wraps.db") + @patch("controllers.console.wraps.os.environ.get") + def test_should_raise_not_init_validate_error_with_init_password(self, mock_environ_get, mock_db): + """Test NotInitValidateError when INIT_PASSWORD is set but setup not complete""" + # Arrange + mock_db.session.query.return_value.first.return_value = None # No setup + mock_environ_get.return_value = "some_password" + + @setup_required + def admin_view(): + return "admin_success" + + # Act & Assert + with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with pytest.raises(NotInitValidateError): + admin_view() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.wraps.os.environ.get") + def test_should_raise_not_setup_error_without_init_password(self, mock_environ_get, mock_db): + """Test NotSetupError when no INIT_PASSWORD and setup not complete""" + # Arrange + mock_db.session.query.return_value.first.return_value = None # No setup + mock_environ_get.return_value = None # No INIT_PASSWORD + + @setup_required + def admin_view(): + return "admin_success" + + # Act & Assert + with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with pytest.raises(NotSetupError): + admin_view() + + +class TestEnterpriseLicense: + """Test enterprise license decorator""" + + def test_should_allow_with_valid_license(self): + """Test that valid licenses allow access""" + # Arrange + mock_settings = MagicMock() + mock_settings.license.status = LicenseStatus.ACTIVE + + @enterprise_license_required + def enterprise_feature(): + return "enterprise_success" + + # Act + with patch("controllers.console.wraps.FeatureService.get_system_features", return_value=mock_settings): + result = enterprise_feature() + + # Assert + assert result == "enterprise_success" + + @pytest.mark.parametrize("invalid_status", [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]) + def test_should_reject_with_invalid_license(self, invalid_status): + """Test that invalid licenses raise UnauthorizedAndForceLogout""" + # Arrange + mock_settings = MagicMock() + mock_settings.license.status = invalid_status + + @enterprise_license_required + def enterprise_feature(): + return "enterprise_success" + + # Act & Assert + with patch("controllers.console.wraps.FeatureService.get_system_features", return_value=mock_settings): + with pytest.raises(UnauthorizedAndForceLogout) as exc_info: + enterprise_feature() + assert "license is invalid" in str(exc_info.value) From bf542233a93d2d637b58cb5c5d4c1ce39d6bd053 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:57:08 +0800 Subject: [PATCH 04/91] minor fix: using Pydantic model_validate instead of deprecated parse_obj (#22239) Signed-off-by: neatguycoding <15627489+NeatGuyCoding@users.noreply.github.com> --- api/core/mcp/auth/auth_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py index b63478e822..bcb31a816f 100644 --- a/api/core/mcp/auth/auth_flow.py +++ b/api/core/mcp/auth/auth_flow.py @@ -240,7 +240,7 @@ def refresh_authorization( response = requests.post(token_url, data=params) if not response.ok: raise ValueError(f"Token refresh failed: HTTP {response.status_code}") - return OAuthTokens.parse_obj(response.json()) + return OAuthTokens.model_validate(response.json()) def register_client( From 0dee41c07447e57a5214f6d68bcc31e51cf050c1 Mon Sep 17 00:00:00 2001 From: yolofit Date: Wed, 16 Jul 2025 11:22:54 +0800 Subject: [PATCH 05/91] fix: When var value changed, PromptEditor should be reset (#22219) --- .../if-else/components/condition-list/condition-item.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index 775f232fd0..eabc10b168 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -25,6 +25,7 @@ import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from '../../../cons import ConditionWrap from '../condition-wrap' import ConditionOperator from './condition-operator' import ConditionInput from './condition-input' +import { useWorkflowStore } from '@/app/components/workflow/store' import ConditionVarSelector from './condition-var-selector' import type { @@ -88,6 +89,11 @@ const ConditionItem = ({ const [isHovered, setIsHovered] = useState(false) const [open, setOpen] = useState(false) + const workflowStore = useWorkflowStore() + const { + setControlPromptEditorRerenderKey, + } = workflowStore.getState() + const doUpdateCondition = useCallback((newCondition: Condition) => { if (isSubVariableKey) onUpdateSubVariableCondition?.(caseId, conditionId, condition.id, newCondition) @@ -208,10 +214,11 @@ const ConditionItem = ({ draft.varType = resolvedVarType draft.value = '' draft.comparison_operator = getOperators(resolvedVarType)[0] + setTimeout(() => setControlPromptEditorRerenderKey(Date.now())) }) doUpdateCondition(newCondition) setOpen(false) - }, [condition, doUpdateCondition, availableNodes, isChatMode]) + }, [condition, doUpdateCondition, availableNodes, isChatMode, setControlPromptEditorRerenderKey]) return (
From 229b4d621e35e5621d78e49050dfc322c0c203bb Mon Sep 17 00:00:00 2001 From: Kerwin Bryant Date: Wed, 16 Jul 2025 11:26:54 +0800 Subject: [PATCH 06/91] Improve Tooltip UX by enabling delay by default (#21383) --- .../(datasetDetailLayout)/[datasetId]/layout-main.tsx | 2 -- .../app/configuration/config/agent/agent-tools/index.tsx | 3 --- .../components/app/configuration/prompt-value-panel/index.tsx | 2 +- web/app/components/base/tooltip/index.spec.tsx | 2 +- web/app/components/base/tooltip/index.tsx | 4 ++-- web/app/components/datasets/documents/list.tsx | 2 -- .../components/header/account-setting/members-page/index.tsx | 1 - .../model-parameter-modal/status-indicators.tsx | 2 -- .../provider-added-card/model-list-item.tsx | 1 - .../components/plugins/plugin-detail-panel/endpoint-list.tsx | 1 - .../plugin-detail-panel/multiple-tool-selector/index.tsx | 1 - .../plugins/plugin-detail-panel/tool-selector/tool-item.tsx | 1 - .../nodes/_base/components/agent-strategy-selector.tsx | 1 - .../workflow/nodes/_base/components/prompt/editor.tsx | 1 - web/app/signin/oneMoreStep.tsx | 1 - 15 files changed, 4 insertions(+), 21 deletions(-) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index acaae3f720..426778c835 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -62,7 +62,6 @@ const ExtraInfo = ({ isMobile, relatedApps, expand }: IExtraInfoProps) => { {
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 66fe85a170..a1b82ab2fe 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -209,7 +209,6 @@ const AgentTools: FC = () => { {item.tool_label} {!item.isDeleted && (
{item.tool_name}
@@ -232,7 +231,6 @@ const AgentTools: FC = () => {
@@ -259,7 +257,6 @@ const AgentTools: FC = () => { {!item.notAuthor && (
{ setCurrentTool(item) diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index e509ee50e4..b36bf8848a 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -177,7 +177,7 @@ const PromptValuePanel: FC = ({
{canNotRun && ( - + @@ -53,7 +53,7 @@ export default function SocialAuth(props: SocialAuthProps) { 'mr-2 h-5 w-5', ) } /> - {t('login.withGoogle')} + {t('login.withGoogle')} diff --git a/web/app/signin/page.module.css b/web/app/signin/page.module.css index eda396f763..72ce7fbd8a 100644 --- a/web/app/signin/page.module.css +++ b/web/app/signin/page.module.css @@ -1,7 +1,7 @@ .githubIcon { - background: center/contain url('./assets/github.svg'); + background: center/contain url('./assets/github.svg') no-repeat; } .googleIcon { - background: center/contain url('./assets/google.svg'); + background: center/contain url('./assets/google.svg') no-repeat; } From a324d3942e05c48a11429e8e51f5887b0330f1b1 Mon Sep 17 00:00:00 2001 From: NFish Date: Thu, 17 Jul 2025 10:52:10 +0800 Subject: [PATCH 22/91] Perf/web app authrozation (#22524) --- .../explore/installed/[appId]/page.tsx | 14 +-- web/app/(shareLayout)/chat/[token]/page.tsx | 5 +- .../(shareLayout)/chatbot/[token]/page.tsx | 5 +- .../(shareLayout)/completion/[token]/page.tsx | 5 +- .../components/authenticated-layout.tsx | 84 ++++++++++++++++ web/app/(shareLayout)/components/splash.tsx | 80 ++++++++++++++++ web/app/(shareLayout)/layout.tsx | 57 ++--------- .../(shareLayout)/webapp-signin/layout.tsx | 9 +- .../webapp-signin/normalForm.tsx | 1 + web/app/(shareLayout)/webapp-signin/page.tsx | 83 ++-------------- .../(shareLayout)/workflow/[token]/page.tsx | 5 +- .../base/chat/chat-with-history/context.tsx | 8 +- .../base/chat/chat-with-history/hooks.tsx | 22 +---- .../base/chat/chat-with-history/index.tsx | 50 +--------- .../base/chat/embedded-chatbot/index.tsx | 94 ------------------ web/app/components/base/chat/types.ts | 10 ++ web/app/components/explore/index.tsx | 3 + .../explore/installed-app/index.tsx | 95 ++++++++++++++++-- web/app/components/explore/sidebar/index.tsx | 30 +++--- .../share/text-generation/index.tsx | 96 ++++--------------- .../share/text-generation/menu-dropdown.tsx | 4 +- web/app/components/share/utils.ts | 2 +- web/context/explore-context.ts | 4 + web/context/global-public-context.tsx | 5 - web/context/web-app-context.tsx | 87 +++++++++++++++++ web/i18n/en-US/login.ts | 1 + web/i18n/ja-JP/login.ts | 1 + web/i18n/zh-Hans/login.ts | 1 + web/models/share.ts | 2 +- web/service/access-control.ts | 28 +++--- web/service/base.ts | 4 +- web/service/explore.ts | 5 + web/service/share.ts | 7 -- web/service/use-explore.ts | 81 ++++++++++++++++ web/service/use-share.ts | 43 ++++++++- 35 files changed, 591 insertions(+), 440 deletions(-) create mode 100644 web/app/(shareLayout)/components/authenticated-layout.tsx create mode 100644 web/app/(shareLayout)/components/splash.tsx create mode 100644 web/context/web-app-context.tsx create mode 100644 web/service/use-explore.ts diff --git a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx index 938a03992b..e288c62b5d 100644 --- a/web/app/(commonLayout)/explore/installed/[appId]/page.tsx +++ b/web/app/(commonLayout)/explore/installed/[appId]/page.tsx @@ -1,16 +1,18 @@ -import type { FC } from 'react' import React from 'react' import Main from '@/app/components/explore/installed-app' export type IInstalledAppProps = { - params: Promise<{ + params: { appId: string - }> + } } -const InstalledApp: FC = async ({ params }) => { +// Using Next.js page convention for async server components +async function InstalledApp({ params }: IInstalledAppProps) { + const appId = (await params).appId return ( -
+
) } -export default React.memo(InstalledApp) + +export default InstalledApp diff --git a/web/app/(shareLayout)/chat/[token]/page.tsx b/web/app/(shareLayout)/chat/[token]/page.tsx index 640c40378f..8ce67585f0 100644 --- a/web/app/(shareLayout)/chat/[token]/page.tsx +++ b/web/app/(shareLayout)/chat/[token]/page.tsx @@ -1,10 +1,13 @@ 'use client' import React from 'react' import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history' +import AuthenticatedLayout from '../../components/authenticated-layout' const Chat = () => { return ( - + + + ) } diff --git a/web/app/(shareLayout)/chatbot/[token]/page.tsx b/web/app/(shareLayout)/chatbot/[token]/page.tsx index 6196afecc4..5323d0dacc 100644 --- a/web/app/(shareLayout)/chatbot/[token]/page.tsx +++ b/web/app/(shareLayout)/chatbot/[token]/page.tsx @@ -1,10 +1,13 @@ 'use client' import React from 'react' import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot' +import AuthenticatedLayout from '../../components/authenticated-layout' const Chatbot = () => { return ( - + + + ) } diff --git a/web/app/(shareLayout)/completion/[token]/page.tsx b/web/app/(shareLayout)/completion/[token]/page.tsx index e8bc9d79f5..ae91338b9a 100644 --- a/web/app/(shareLayout)/completion/[token]/page.tsx +++ b/web/app/(shareLayout)/completion/[token]/page.tsx @@ -1,9 +1,12 @@ import React from 'react' import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../components/authenticated-layout' const Completion = () => { return ( -
+ +
+ ) } diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx new file mode 100644 index 0000000000..e3cfc8e6a8 --- /dev/null +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -0,0 +1,84 @@ +'use client' + +import AppUnavailable from '@/app/components/base/app-unavailable' +import Loading from '@/app/components/base/loading' +import { removeAccessToken } from '@/app/components/share/utils' +import { useWebAppStore } from '@/context/web-app-context' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import React, { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { + const { t } = useTranslation() + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) + const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams() + const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo() + const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta() + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false }) + + useEffect(() => { + if (appInfo) + updateAppInfo(appInfo) + if (appParams) + updateAppParams(appParams) + if (appMeta) + updateWebAppMeta(appMeta) + updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + }, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp]) + + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + params.set('redirect_url', pathname) + return `/webapp-signin?${params.toString()}` + }, [searchParams, pathname]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + if (appInfoError) { + return
+ +
+ } + if (appParamsError) { + return
+ +
+ } + if (appMetaError) { + return
+ +
+ } + if (useCanAccessAppError) { + return
+ +
+ } + if (userCanAccessApp && !userCanAccessApp.result) { + return
+ + {t('common.userProfile.logout')} +
+ } + if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) { + return
+ +
+ } + return <>{children} +} + +export default React.memo(AuthenticatedLayout) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx new file mode 100644 index 0000000000..4fe9efe4dd --- /dev/null +++ b/web/app/(shareLayout)/components/splash.tsx @@ -0,0 +1,80 @@ +'use client' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useCallback } from 'react' +import { useWebAppStore } from '@/context/web-app-context' +import { useRouter, useSearchParams } from 'next/navigation' +import AppUnavailable from '@/app/components/base/app-unavailable' +import { checkOrSetAccessToken, removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { useTranslation } from 'react-i18next' +import { fetchAccessToken } from '@/service/share' +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' + +const Splash: FC = ({ children }) => { + const { t } = useTranslation() + const shareCode = useWebAppStore(s => s.shareCode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) + const searchParams = useSearchParams() + const router = useRouter() + const redirectUrl = searchParams.get('redirect_url') + const tokenFromUrl = searchParams.get('web_sso_token') + const message = searchParams.get('message') + const code = searchParams.get('code') + const getSigninUrl = useCallback(() => { + const params = new URLSearchParams(searchParams) + params.delete('message') + params.delete('code') + return `/webapp-signin?${params.toString()}` + }, [searchParams]) + + const backToHome = useCallback(() => { + removeAccessToken() + const url = getSigninUrl() + router.replace(url) + }, [getSigninUrl, router]) + + useEffect(() => { + (async () => { + if (message) + return + if (shareCode && tokenFromUrl && redirectUrl) { + localStorage.setItem('webapp_access_token', tokenFromUrl) + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: tokenFromUrl }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (shareCode && redirectUrl && localStorage.getItem('webapp_access_token')) { + const tokenResp = await fetchAccessToken({ appCode: shareCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) + await setAccessToken(shareCode, tokenResp.access_token) + router.replace(decodeURIComponent(redirectUrl)) + return + } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + await checkOrSetAccessToken(shareCode) + router.replace(decodeURIComponent(redirectUrl)) + } + })() + }, [shareCode, redirectUrl, router, tokenFromUrl, message, webAppAccessMode]) + + if (message) { + return
+ + {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} +
+ } + if (tokenFromUrl) { + return
+ +
+ } + if (webAppAccessMode === AccessMode.PUBLIC && redirectUrl) { + return
+ +
+ } + return <>{children} +} + +export default Splash diff --git a/web/app/(shareLayout)/layout.tsx b/web/app/(shareLayout)/layout.tsx index d057ba7599..5af913cac9 100644 --- a/web/app/(shareLayout)/layout.tsx +++ b/web/app/(shareLayout)/layout.tsx @@ -1,54 +1,15 @@ -'use client' -import React, { useEffect, useState } from 'react' -import type { FC } from 'react' -import { usePathname, useSearchParams } from 'next/navigation' -import Loading from '../components/base/loading' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { AccessMode } from '@/models/access-control' -import { getAppAccessModeByAppCode } from '@/service/share' +import type { FC, PropsWithChildren } from 'react' +import WebAppStoreProvider from '@/context/web-app-context' +import Splash from './components/splash' -const Layout: FC<{ - children: React.ReactNode -}> = ({ children }) => { - const isGlobalPending = useGlobalPublicStore(s => s.isGlobalPending) - const setWebAppAccessMode = useGlobalPublicStore(s => s.setWebAppAccessMode) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const pathname = usePathname() - const searchParams = useSearchParams() - const redirectUrl = searchParams.get('redirect_url') - const [isLoading, setIsLoading] = useState(true) - useEffect(() => { - (async () => { - if (!isGlobalPending && !systemFeatures.webapp_auth.enabled) { - setIsLoading(false) - return - } - - let appCode: string | null = null - if (redirectUrl) { - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - appCode = url.pathname.split('/').pop() || null - } - else { - appCode = pathname.split('/').pop() || null - } - - if (!appCode) - return - setIsLoading(true) - const ret = await getAppAccessModeByAppCode(appCode) - setWebAppAccessMode(ret?.accessMode || AccessMode.PUBLIC) - setIsLoading(false) - })() - }, [pathname, redirectUrl, setWebAppAccessMode, isGlobalPending, systemFeatures.webapp_auth.enabled]) - if (isLoading || isGlobalPending) { - return
- -
- } +const Layout: FC = ({ children }) => { return (
- {children} + + + {children} + +
) } diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index a03364d326..7649982072 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -3,10 +3,13 @@ import cn from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import type { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' -export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() - useDocumentTitle('') +export default function SignInLayout({ children }: PropsWithChildren) { + const { t } = useTranslation() + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + useDocumentTitle(t('login.webapp.login')) return <>
diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index d6bdf607ba..44006a9f1e 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,3 +1,4 @@ +'use client' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Link from 'next/link' diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 967516c416..1c6209b902 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,36 +1,30 @@ 'use client' import { useRouter, useSearchParams } from 'next/navigation' import type { FC } from 'react' -import React, { useCallback, useEffect } from 'react' +import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' -import { removeAccessToken, setAccessToken } from '@/app/components/share/utils' +import { removeAccessToken } from '@/app/components/share/utils' import { useGlobalPublicStore } from '@/context/global-public-context' -import Loading from '@/app/components/base/loading' import AppUnavailable from '@/app/components/base/app-unavailable' import NormalForm from './normalForm' import { AccessMode } from '@/models/access-control' import ExternalMemberSsoAuth from './components/external-member-sso-auth' -import { fetchAccessToken } from '@/service/share' +import { useWebAppStore } from '@/context/web-app-context' const WebSSOForm: FC = () => { const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() const redirectUrl = searchParams.get('redirect_url') - const tokenFromUrl = searchParams.get('web_sso_token') - const message = searchParams.get('message') - const code = searchParams.get('code') const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.delete('code') + const params = new URLSearchParams() + params.append('redirect_url', redirectUrl || '') return `/webapp-signin?${params.toString()}` - }, [searchParams]) + }, [redirectUrl]) const backToHome = useCallback(() => { removeAccessToken() @@ -38,73 +32,12 @@ const WebSSOForm: FC = () => { router.replace(url) }, [getSigninUrl, router]) - const showErrorToast = (msg: string) => { - Toast.notify({ - type: 'error', - message: msg, - }) - } - - const getAppCodeFromRedirectUrl = useCallback(() => { - if (!redirectUrl) - return null - const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) - const appCode = url.pathname.split('/').pop() - if (!appCode) - return null - - return appCode - }, [redirectUrl]) - - useEffect(() => { - (async () => { - if (message) - return - - const appCode = getAppCodeFromRedirectUrl() - if (appCode && tokenFromUrl && redirectUrl) { - localStorage.setItem('webapp_access_token', tokenFromUrl) - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: tokenFromUrl }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - return - } - if (appCode && redirectUrl && localStorage.getItem('webapp_access_token')) { - const tokenResp = await fetchAccessToken({ appCode, webAppAccessToken: localStorage.getItem('webapp_access_token') }) - await setAccessToken(appCode, tokenResp.access_token) - router.replace(decodeURIComponent(redirectUrl)) - } - })() - }, [getAppCodeFromRedirectUrl, redirectUrl, router, tokenFromUrl, message]) - - useEffect(() => { - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC && redirectUrl) - router.replace(decodeURIComponent(redirectUrl)) - }, [webAppAccessMode, router, redirectUrl]) - - if (tokenFromUrl) { - return
- -
- } - - if (message) { - return
- - {code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')} -
- } if (!redirectUrl) { - showErrorToast('redirect url is invalid.') return
} - if (webAppAccessMode && webAppAccessMode === AccessMode.PUBLIC) { - return
- -
- } + if (!systemFeatures.webapp_auth.enabled) { return

{t('login.webapp.disabled')}

diff --git a/web/app/(shareLayout)/workflow/[token]/page.tsx b/web/app/(shareLayout)/workflow/[token]/page.tsx index e93bc8c1af..4f5923e91f 100644 --- a/web/app/(shareLayout)/workflow/[token]/page.tsx +++ b/web/app/(shareLayout)/workflow/[token]/page.tsx @@ -1,10 +1,13 @@ import React from 'react' import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../components/authenticated-layout' const Workflow = () => { return ( -
+ +
+ ) } diff --git a/web/app/components/base/chat/chat-with-history/context.tsx b/web/app/components/base/chat/chat-with-history/context.tsx index 5bf1514774..3a5dc793d6 100644 --- a/web/app/components/base/chat/chat-with-history/context.tsx +++ b/web/app/components/base/chat/chat-with-history/context.tsx @@ -18,11 +18,8 @@ import type { import { noop } from 'lodash-es' export type ChatWithHistoryContextValue = { - appInfoError?: any - appInfoLoading?: boolean - appMeta?: AppMeta - appData?: AppData - userCanAccess?: boolean + appMeta?: AppMeta | null + appData?: AppData | null appParams?: ChatConfig appChatListDataLoading?: boolean currentConversationId: string @@ -62,7 +59,6 @@ export type ChatWithHistoryContextValue = { } export const ChatWithHistoryContext = createContext({ - userCanAccess: false, currentConversationId: '', appPrevChatTree: [], pinnedConversationList: [], diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 32f74e6457..be935a70ba 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -21,9 +21,6 @@ import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { delConversation, - fetchAppInfo, - fetchAppMeta, - fetchAppParams, fetchChatList, fetchConversations, generationConversationName, @@ -43,8 +40,7 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { noop } from 'lodash-es' -import { useGetUserCanAccessApp } from '@/service/access-control' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { useWebAppStore } from '@/context/web-app-context' function getFormattedChatList(messages: any[]) { const newChatList: ChatItem[] = [] @@ -74,13 +70,9 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) - const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) - const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ - appId: installedAppInfo?.app.id || appInfo?.app_id, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) + const appInfo = useWebAppStore(s => s.appInfo) + const appParams = useWebAppStore(s => s.appParams) + const appMeta = useWebAppStore(s => s.appMeta) useAppFavicon({ enable: !installedAppInfo, @@ -107,6 +99,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { use_icon_as_answer_icon: app.use_icon_as_answer_icon, }, plan: 'basic', + custom_config: null, } as AppData } @@ -166,8 +159,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { return currentConversationId }, [currentConversationId, newConversationId]) - const { data: appParams } = useSWR(['appParams', isInstalledApp, appId], () => fetchAppParams(isInstalledApp, appId)) - const { data: appMeta } = useSWR(['appMeta', isInstalledApp, appId], () => fetchAppMeta(isInstalledApp, appId)) const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(['appConversationData', isInstalledApp, appId, true], () => fetchConversations(isInstalledApp, appId, undefined, true, 100)) const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100)) const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId)) @@ -485,9 +476,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [isInstalledApp, appId, t, notify]) return { - appInfoError, - appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission), - userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true, isInstalledApp, appId, currentConversationId, diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index fe8e7b430d..cceb21b295 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import { - useCallback, useEffect, useState, } from 'react' @@ -19,12 +18,10 @@ import ChatWrapper from './chat-wrapper' import type { InstalledApp } from '@/models/explore' import Loading from '@/app/components/base/loading' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils' +import { checkOrSetAccessToken } from '@/app/components/share/utils' import AppUnavailable from '@/app/components/base/app-unavailable' import cn from '@/utils/classnames' import useDocumentTitle from '@/hooks/use-document-title' -import { useTranslation } from 'react-i18next' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' type ChatWithHistoryProps = { className?: string @@ -33,16 +30,12 @@ const ChatWithHistory: FC = ({ className, }) => { const { - userCanAccess, - appInfoError, appData, - appInfoLoading, appChatListDataLoading, chatShouldReloadKey, isMobile, themeBuilder, sidebarCollapseState, - isInstalledApp, } = useChatWithHistoryContext() const isSidebarCollapsed = sidebarCollapseState const customConfig = appData?.custom_config @@ -56,41 +49,6 @@ const ChatWithHistory: FC = ({ useDocumentTitle(site?.title || 'Chat') - const { t } = useTranslation() - const searchParams = useSearchParams() - const router = useRouter() - const pathname = usePathname() - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (appInfoLoading) { - return ( - - ) - } - if (!userCanAccess) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - - if (appInfoError) { - return ( - - ) - } - return (
= ({ const themeBuilder = useThemeContext() const { - appInfoError, - appInfoLoading, - userCanAccess, appData, appParams, appMeta, @@ -191,10 +146,7 @@ const ChatWithHistoryWrap: FC = ({ return ( { const { - userCanAccess, isMobile, allowResetChat, - appInfoError, - appInfoLoading, appData, appChatListDataLoading, chatShouldReloadKey, handleNewConversation, themeBuilder, - isInstalledApp, } = useEmbeddedChatbotContext() const { t } = useTranslation() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) @@ -55,58 +45,6 @@ const Chatbot = () => { useDocumentTitle(site?.title || 'Chat') - const searchParams = useSearchParams() - const router = useRouter() - const pathname = usePathname() - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (appInfoLoading) { - return ( - <> - {!isMobile && } - {isMobile && ( -
-
- -
-
- )} - - ) - } - - if (!userCanAccess) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - - if (appInfoError) { - return ( - <> - {!isMobile && } - {isMobile && ( -
-
- -
-
- )} - - ) - } return (
{ const themeBuilder = useThemeContext() const { - appInfoError, - appInfoLoading, appData, userCanAccess, appParams, @@ -200,8 +136,6 @@ const EmbeddedChatbotWrapper = () => { return { } const EmbeddedChatbot = () => { - const [initialized, setInitialized] = useState(false) - const [appUnavailable, setAppUnavailable] = useState(false) - const [isUnknownReason, setIsUnknownReason] = useState(false) - - useAsyncEffect(async () => { - if (!initialized) { - try { - await checkOrSetAccessToken() - } - catch (e: any) { - if (e.status === 404) { - setAppUnavailable(true) - } - else { - setIsUnknownReason(true) - setAppUnavailable(true) - } - } - setInitialized(true) - } - }, []) - - if (!initialized) - return null - - if (appUnavailable) - return - return } diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index 91f9bc976b..c463879a53 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -49,6 +49,16 @@ export type ChatConfig = Omit & { questionEditEnable?: boolean supportFeedback?: boolean supportCitationHitInfo?: boolean + system_parameters: { + audio_file_size_limit: number + file_size_limit: number + image_file_size_limit: number + video_file_size_limit: number + workflow_file_upload_limit: number + } + more_like_this: { + enabled: boolean + } } export type WorkflowProcess = { diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index bae2610cba..e716de96f1 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -22,6 +22,7 @@ const Explore: FC = ({ const { userProfile, isCurrentWorkspaceDatasetOperator } = useAppContext() const [hasEditPermission, setHasEditPermission] = useState(false) const [installedApps, setInstalledApps] = useState([]) + const [isFetchingInstalledApps, setIsFetchingInstalledApps] = useState(false) const { t } = useTranslation() useDocumentTitle(t('common.menus.explore')) @@ -51,6 +52,8 @@ const Explore: FC = ({ hasEditPermission, installedApps, setInstalledApps, + isFetchingInstalledApps, + setIsFetchingInstalledApps, } } > diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index 71013fc2e1..8032e173c6 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -1,11 +1,17 @@ 'use client' import type { FC } from 'react' +import { useEffect } from 'react' import React from 'react' import { useContext } from 'use-context-selector' import ExploreContext from '@/context/explore-context' import TextGenerationApp from '@/app/components/share/text-generation' import Loading from '@/app/components/base/loading' import ChatWithHistory from '@/app/components/base/chat/chat-with-history' +import { useWebAppStore } from '@/context/web-app-context' +import AppUnavailable from '../../base/app-unavailable' +import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore' +import type { AppData } from '@/models/share' export type IInstalledAppProps = { id: string @@ -14,26 +20,95 @@ export type IInstalledAppProps = { const InstalledApp: FC = ({ id, }) => { - const { installedApps } = useContext(ExploreContext) + const { installedApps, isFetchingInstalledApps } = useContext(ExploreContext) + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) const installedApp = installedApps.find(item => item.id === id) + const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode) + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) + const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) + const { isFetching: isFetchingWebAppAccessMode, data: webAppAccessMode, error: webAppAccessModeError } = useGetInstalledAppAccessModeByAppId(installedApp?.id ?? null) + const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetInstalledAppParams(installedApp?.id ?? null) + const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetInstalledAppMeta(installedApp?.id ?? null) + const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: installedApp?.app.id, isInstalledApp: true }) + useEffect(() => { + if (!installedApp) { + updateAppInfo(null) + } + else { + const { id, app } = installedApp + updateAppInfo({ + app_id: id, + site: { + title: app.name, + icon_type: app.icon_type, + icon: app.icon, + icon_background: app.icon_background, + icon_url: app.icon_url, + prompt_public: false, + copyright: '', + show_workflow_steps: true, + use_icon_as_answer_icon: app.use_icon_as_answer_icon, + }, + plan: 'basic', + custom_config: null, + } as AppData) + } + + if (appParams) + updateAppParams(appParams) + if (appMeta) + updateWebAppMeta(appMeta) + if (webAppAccessMode) + updateWebAppAccessMode(webAppAccessMode.accessMode) + updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) + + if (appParamsError) { + return
+ +
+ } + if (appMetaError) { + return
+ +
+ } + if (useCanAccessAppError) { + return
+ +
+ } + if (webAppAccessModeError) { + return
+ +
+ } + if (userCanAccessApp && !userCanAccessApp.result) { + return
+ +
+ } + if (isFetchingAppParams || isFetchingAppMeta || isFetchingWebAppAccessMode || isFetchingInstalledApps) { + return
+ +
+ } if (!installedApp) { - return ( -
- -
- ) + return
+ +
} - return (
- {installedApp.app.mode !== 'completion' && installedApp.app.mode !== 'workflow' && ( + {installedApp?.app.mode !== 'completion' && installedApp?.app.mode !== 'workflow' && ( )} - {installedApp.app.mode === 'completion' && ( + {installedApp?.app.mode === 'completion' && ( )} - {installedApp.app.mode === 'workflow' && ( + {installedApp?.app.mode === 'workflow' && ( )}
diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index fe5935bcd3..74c397f4fd 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -8,11 +8,11 @@ import Link from 'next/link' import Toast from '../../base/toast' import Item from './app-nav-item' import cn from '@/utils/classnames' -import { fetchInstalledAppList as doFetchInstalledAppList, uninstallApp, updatePinStatus } from '@/service/explore' import ExploreContext from '@/context/explore-context' import Confirm from '@/app/components/base/confirm' import Divider from '@/app/components/base/divider' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' const SelectedDiscoveryIcon = () => ( @@ -50,16 +50,14 @@ const SideBar: FC = ({ const lastSegment = segments.slice(-1)[0] const isDiscoverySelected = lastSegment === 'apps' const isChatSelected = lastSegment === 'chat' - const { installedApps, setInstalledApps } = useContext(ExploreContext) + const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext) + const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps() + const { mutateAsync: uninstallApp } = useUninstallApp() + const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus() const media = useBreakpoints() const isMobile = media === MediaType.mobile - const fetchInstalledAppList = async () => { - const { installed_apps }: any = await doFetchInstalledAppList() - setInstalledApps(installed_apps) - } - const [showConfirm, setShowConfirm] = useState(false) const [currId, setCurrId] = useState('') const handleDelete = async () => { @@ -70,25 +68,31 @@ const SideBar: FC = ({ type: 'success', message: t('common.api.remove'), }) - fetchInstalledAppList() } const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { - await updatePinStatus(id, isPinned) + await updatePinStatus({ appId: id, isPinned }) Toast.notify({ type: 'success', message: t('common.api.success'), }) - fetchInstalledAppList() } useEffect(() => { - fetchInstalledAppList() - }, []) + const installed_apps = (ret as any)?.installed_apps + if (installed_apps && installed_apps.length > 0) + setInstalledApps(installed_apps) + else + setInstalledApps([]) + }, [ret, setInstalledApps]) + + useEffect(() => { + setIsFetchingInstalledApps(isFetchingInstalledApps) + }, [isFetchingInstalledApps, setIsFetchingInstalledApps]) useEffect(() => { fetchInstalledAppList() - }, [controlUpdateInstalledApps]) + }, [controlUpdateInstalledApps, fetchInstalledAppList]) const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length return ( diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 4be6b18958..ae6e733e49 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -7,16 +7,14 @@ import { RiErrorWarningFill, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import TabHeader from '../../base/tab-header' -import { checkOrSetAccessToken, removeAccessToken } from '../utils' import MenuDropdown from './menu-dropdown' import RunBatch from './run-batch' import ResDownload from './run-batch/res-download' -import AppUnavailable from '../../base/app-unavailable' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import RunOnce from '@/app/components/share/text-generation/run-once' -import { fetchSavedMessage as doFetchSavedMessage, fetchAppInfo, fetchAppParams, removeMessage, saveMessage } from '@/service/share' +import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' import type { SiteInfo } from '@/models/share' import type { MoreLikeThisConfig, @@ -39,10 +37,10 @@ import { Resolution, TransferMethod } from '@/types/app' import { useAppFavicon } from '@/hooks/use-app-favicon' import DifyLogo from '@/app/components/base/logo/dify-logo' import cn from '@/utils/classnames' -import { useGetAppAccessMode, useGetUserCanAccessApp } from '@/service/access-control' import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useWebAppStore } from '@/context/web-app-context' const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group. enum TaskStatus { @@ -83,9 +81,6 @@ const TextGeneration: FC = ({ const mode = searchParams.get('mode') || 'create' const [currentTab, setCurrentTab] = useState(['create', 'batch'].includes(mode) ? mode : 'create') - const router = useRouter() - const pathname = usePathname() - // Notice this situation isCallBatchAPI but not in batch tab const [isCallBatchAPI, setIsCallBatchAPI] = useState(false) const isInBatchTab = currentTab === 'batch' @@ -103,30 +98,19 @@ const TextGeneration: FC = ({ const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) - const { isPending: isGettingAccessMode, data: appAccessMode } = useGetAppAccessMode({ - appId, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) - const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({ - appId, - isInstalledApp, - enabled: systemFeatures.webapp_auth.enabled, - }) - // save message const [savedMessages, setSavedMessages] = useState([]) - const fetchSavedMessage = async () => { - const res: any = await doFetchSavedMessage(isInstalledApp, installedAppInfo?.id) + const fetchSavedMessage = useCallback(async () => { + const res: any = await doFetchSavedMessage(isInstalledApp, appId) setSavedMessages(res.data) - } + }, [isInstalledApp, appId]) const handleSaveMessage = async (messageId: string) => { - await saveMessage(messageId, isInstalledApp, installedAppInfo?.id) + await saveMessage(messageId, isInstalledApp, appId) notify({ type: 'success', message: t('common.api.saved') }) fetchSavedMessage() } const handleRemoveSavedMessage = async (messageId: string) => { - await removeMessage(messageId, isInstalledApp, installedAppInfo?.id) + await removeMessage(messageId, isInstalledApp, appId) notify({ type: 'success', message: t('common.api.remove') }) fetchSavedMessage() } @@ -375,34 +359,14 @@ const TextGeneration: FC = ({ } } - const fetchInitData = async () => { - if (!isInstalledApp) - await checkOrSetAccessToken() - - return Promise.all([ - isInstalledApp - ? { - app_id: installedAppInfo?.id, - site: { - title: installedAppInfo?.app.name, - prompt_public: false, - copyright: '', - icon: installedAppInfo?.app.icon, - icon_background: installedAppInfo?.app.icon_background, - }, - plan: 'basic', - } - : fetchAppInfo(), - fetchAppParams(isInstalledApp, installedAppInfo?.id), - !isWorkflow - ? fetchSavedMessage() - : {}, - ]) - } - + const appData = useWebAppStore(s => s.appInfo) + const appParams = useWebAppStore(s => s.appParams) + const accessMode = useWebAppStore(s => s.webAppAccessMode) useEffect(() => { (async () => { - const [appData, appParams]: any = await fetchInitData() + if (!appData || !appParams) + return + !isWorkflow && fetchSavedMessage() const { app_id: appId, site: siteInfo, custom_config } = appData setAppId(appId) setSiteInfo(siteInfo as SiteInfo) @@ -413,11 +377,11 @@ const TextGeneration: FC = ({ setVisionConfig({ // legacy of image upload compatible ...file_upload, - transfer_methods: file_upload.allowed_file_upload_methods || file_upload.allowed_upload_methods, + transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, // legacy of image upload compatible - image_file_size_limit: appParams?.system_parameters?.image_file_size_limit, + image_file_size_limit: appParams?.system_parameters.image_file_size_limit, fileUploadConfig: appParams?.system_parameters, - }) + } as any) const prompt_variables = userInputsFormToPromptVariables(user_input_form) setPromptConfig({ prompt_template: '', // placeholder for future @@ -426,7 +390,7 @@ const TextGeneration: FC = ({ setMoreLikeThisConfig(more_like_this) setTextToSpeechConfig(text_to_speech) })() - }, []) + }, [appData, appParams, fetchSavedMessage, isWorkflow]) // Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client. useDocumentTitle(siteInfo?.title || t('share.generation.title')) @@ -528,32 +492,12 @@ const TextGeneration: FC = ({
) - const getSigninUrl = useCallback(() => { - const params = new URLSearchParams(searchParams) - params.delete('message') - params.set('redirect_url', pathname) - return `/webapp-signin?${params.toString()}` - }, [searchParams, pathname]) - - const backToHome = useCallback(() => { - removeAccessToken() - const url = getSigninUrl() - router.replace(url) - }, [getSigninUrl, router]) - - if (!appId || !siteInfo || !promptConfig || (systemFeatures.webapp_auth.enabled && (isGettingAccessMode || isCheckingPermission))) { + if (!appId || !siteInfo || !promptConfig) { return (
) } - if (systemFeatures.webapp_auth.enabled && !userCanAccessResult?.result) { - return
- - {!isInstalledApp && {t('common.userProfile.logout')}} -
- } - return (
= ({ imageUrl={siteInfo.icon_url} />
{siteInfo.title}
- +
{siteInfo.description && (
{siteInfo.description}
diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index adb926c7ca..1c1b6adfe8 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -18,8 +18,8 @@ import { import ThemeSwitcher from '@/app/components/base/theme-switcher' import type { SiteInfo } from '@/models/share' import cn from '@/utils/classnames' -import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode } from '@/models/access-control' +import { useWebAppStore } from '@/context/web-app-context' type Props = { data?: SiteInfo @@ -32,7 +32,7 @@ const MenuDropdown: FC = ({ placement, hideLogout, }) => { - const webAppAccessMode = useGlobalPublicStore(s => s.webAppAccessMode) + const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const router = useRouter() const pathname = usePathname() const { t } = useTranslation() diff --git a/web/app/components/share/utils.ts b/web/app/components/share/utils.ts index 8a897ab59a..0c6457fb0c 100644 --- a/web/app/components/share/utils.ts +++ b/web/app/components/share/utils.ts @@ -10,7 +10,7 @@ export const getInitialTokenV2 = (): Record => ({ version: 2, }) -export const checkOrSetAccessToken = async (appCode?: string) => { +export const checkOrSetAccessToken = async (appCode?: string | null) => { const sharedToken = appCode || globalThis.location.pathname.split('/').slice(-1)[0] const userId = (await getProcessedSystemVariablesFromUrlParams()).user_id const accessToken = localStorage.getItem('token') || JSON.stringify(getInitialTokenV2()) diff --git a/web/context/explore-context.ts b/web/context/explore-context.ts index 11124bd54a..d8d64fb34c 100644 --- a/web/context/explore-context.ts +++ b/web/context/explore-context.ts @@ -8,6 +8,8 @@ type IExplore = { hasEditPermission: boolean installedApps: InstalledApp[] setInstalledApps: (installedApps: InstalledApp[]) => void + isFetchingInstalledApps: boolean + setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void } const ExploreContext = createContext({ @@ -16,6 +18,8 @@ const ExploreContext = createContext({ hasEditPermission: false, installedApps: [], setInstalledApps: noop, + isFetchingInstalledApps: false, + setIsFetchingInstalledApps: noop, }) export default ExploreContext diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 26ad84be65..324ac019c8 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -7,15 +7,12 @@ import type { SystemFeatures } from '@/types/feature' import { defaultSystemFeatures } from '@/types/feature' import { getSystemFeatures } from '@/service/common' import Loading from '@/app/components/base/loading' -import { AccessMode } from '@/models/access-control' type GlobalPublicStore = { isGlobalPending: boolean setIsGlobalPending: (isPending: boolean) => void systemFeatures: SystemFeatures setSystemFeatures: (systemFeatures: SystemFeatures) => void - webAppAccessMode: AccessMode, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => void } export const useGlobalPublicStore = create(set => ({ @@ -23,8 +20,6 @@ export const useGlobalPublicStore = create(set => ({ setIsGlobalPending: (isPending: boolean) => set(() => ({ isGlobalPending: isPending })), systemFeatures: defaultSystemFeatures, setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })), - webAppAccessMode: AccessMode.PUBLIC, - setWebAppAccessMode: (webAppAccessMode: AccessMode) => set(() => ({ webAppAccessMode })), })) const GlobalPublicStoreProvider: FC = ({ diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx new file mode 100644 index 0000000000..55f95e4811 --- /dev/null +++ b/web/context/web-app-context.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { ChatConfig } from '@/app/components/base/chat/types' +import Loading from '@/app/components/base/loading' +import { AccessMode } from '@/models/access-control' +import type { AppData, AppMeta } from '@/models/share' +import { useGetWebAppAccessModeByCode } from '@/service/use-share' +import { usePathname, useSearchParams } from 'next/navigation' +import type { FC, PropsWithChildren } from 'react' +import { useEffect } from 'react' +import { useState } from 'react' +import { create } from 'zustand' + +type WebAppStore = { + shareCode: string | null + updateShareCode: (shareCode: string | null) => void + appInfo: AppData | null + updateAppInfo: (appInfo: AppData | null) => void + appParams: ChatConfig | null + updateAppParams: (appParams: ChatConfig | null) => void + webAppAccessMode: AccessMode + updateWebAppAccessMode: (accessMode: AccessMode) => void + appMeta: AppMeta | null + updateWebAppMeta: (appMeta: AppMeta | null) => void + userCanAccessApp: boolean + updateUserCanAccessApp: (canAccess: boolean) => void +} + +export const useWebAppStore = create(set => ({ + shareCode: null, + updateShareCode: (shareCode: string | null) => set(() => ({ shareCode })), + appInfo: null, + updateAppInfo: (appInfo: AppData | null) => set(() => ({ appInfo })), + appParams: null, + updateAppParams: (appParams: ChatConfig | null) => set(() => ({ appParams })), + webAppAccessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + updateWebAppAccessMode: (accessMode: AccessMode) => set(() => ({ webAppAccessMode: accessMode })), + appMeta: null, + updateWebAppMeta: (appMeta: AppMeta | null) => set(() => ({ appMeta })), + userCanAccessApp: false, + updateUserCanAccessApp: (canAccess: boolean) => set(() => ({ userCanAccessApp: canAccess })), +})) + +const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { + if (!redirectUrl || redirectUrl.length === 0) + return null + const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`) + return url.pathname.split('/').pop() || null +} +const getShareCodeFromPathname = (pathname: string): string | null => { + const code = pathname.split('/').pop() || null + if (code === 'webapp-signin') + return null + return code +} + +const WebAppStoreProvider: FC = ({ children }) => { + const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) + const updateShareCode = useWebAppStore(state => state.updateShareCode) + const pathname = usePathname() + const searchParams = useSearchParams() + const redirectUrlParam = searchParams.get('redirect_url') + const [shareCode, setShareCode] = useState(null) + useEffect(() => { + const shareCodeFromRedirect = getShareCodeFromRedirectUrl(redirectUrlParam) + const shareCodeFromPathname = getShareCodeFromPathname(pathname) + const newShareCode = shareCodeFromRedirect || shareCodeFromPathname + setShareCode(newShareCode) + updateShareCode(newShareCode) + }, [pathname, redirectUrlParam, updateShareCode]) + const { isFetching, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) + useEffect(() => { + if (accessModeResult?.accessMode) + updateWebAppAccessMode(accessModeResult.accessMode) + }, [accessModeResult, updateWebAppAccessMode]) + if (isFetching) { + return
+ +
+ } + return ( + <> + {children} + + ) +} +export default WebAppStoreProvider diff --git a/web/i18n/en-US/login.ts b/web/i18n/en-US/login.ts index 0beb631d24..d47eb7c079 100644 --- a/web/i18n/en-US/login.ts +++ b/web/i18n/en-US/login.ts @@ -105,6 +105,7 @@ const translation = { licenseInactive: 'License Inactive', licenseInactiveTip: 'The Dify Enterprise license for your workspace is inactive. Please contact your administrator to continue using Dify.', webapp: { + login: 'Login', noLoginMethod: 'Authentication method not configured for web app', noLoginMethodTip: 'Please contact the system admin to add an authentication method.', disabled: 'Webapp authentication is disabled. Please contact the system admin to enable it. You can try to use the app directly.', diff --git a/web/i18n/ja-JP/login.ts b/web/i18n/ja-JP/login.ts index 84ab9eecd0..b37700eba2 100644 --- a/web/i18n/ja-JP/login.ts +++ b/web/i18n/ja-JP/login.ts @@ -106,6 +106,7 @@ const translation = { licenseExpired: 'ライセンスの有効期限が切れています', licenseLostTip: 'Dify ライセンスサーバーへの接続に失敗しました。続けて Dify を使用するために管理者に連絡してください。', webapp: { + login: 'ログイン', noLoginMethod: 'Web アプリに対して認証方法が構成されていません', noLoginMethodTip: 'システム管理者に連絡して、認証方法を追加してください。', disabled: 'Web アプリの認証が無効になっています。システム管理者に連絡して有効にしてください。直接アプリを使用してみてください。', diff --git a/web/i18n/zh-Hans/login.ts b/web/i18n/zh-Hans/login.ts index a37fc104eb..b63630e288 100644 --- a/web/i18n/zh-Hans/login.ts +++ b/web/i18n/zh-Hans/login.ts @@ -106,6 +106,7 @@ const translation = { licenseInactive: '许可证未激活', licenseInactiveTip: '您所在空间的 Dify Enterprise 许可证尚未激活,请联系管理员以继续使用 Dify。', webapp: { + login: '登录', noLoginMethod: 'Web 应用未配置身份认证方式', noLoginMethodTip: '请联系系统管理员添加身份认证方式', disabled: 'Web 应用身份认证已禁用,请联系系统管理员启用。您也可以尝试直接使用应用。', diff --git a/web/models/share.ts b/web/models/share.ts index 3521365e82..1e3b6d6bb7 100644 --- a/web/models/share.ts +++ b/web/models/share.ts @@ -35,7 +35,7 @@ export type AppMeta = { export type AppData = { app_id: string can_replace_logo?: boolean - custom_config?: Record + custom_config: Record | null enable_site?: boolean end_user_id?: string site: SiteInfo diff --git a/web/service/access-control.ts b/web/service/access-control.ts index 36999bf8f3..d4cc9eb792 100644 --- a/web/service/access-control.ts +++ b/web/service/access-control.ts @@ -1,8 +1,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { get, post } from './base' -import { getAppAccessMode, getUserCanAccess } from './share' +import { getUserCanAccess } from './share' import type { AccessControlAccount, AccessControlGroup, AccessMode, Subject } from '@/models/access-control' import type { App } from '@/types/app' +import { useGlobalPublicStore } from '@/context/global-public-context' const NAME_SPACE = 'access-control' @@ -69,25 +70,18 @@ export const useUpdateAccessMode = () => { }) } -export const useGetAppAccessMode = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => { - return useQuery({ - queryKey: [NAME_SPACE, 'app-access-mode', appId], - queryFn: () => getAppAccessMode(appId!, isInstalledApp), - enabled: !!appId && enabled, - staleTime: 0, - gcTime: 0, - }) -} - -export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled }: { appId?: string; isInstalledApp?: boolean; enabled: boolean }) => { +export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true }: { appId?: string; isInstalledApp?: boolean; }) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ queryKey: [NAME_SPACE, 'user-can-access-app', appId], - queryFn: () => getUserCanAccess(appId!, isInstalledApp), - enabled: !!appId && enabled, + queryFn: () => { + if (systemFeatures.webapp_auth.enabled) + return getUserCanAccess(appId!, isInstalledApp) + else + return { result: true } + }, + enabled: !!appId, staleTime: 0, gcTime: 0, - initialData: { - result: !enabled, - }, }) } diff --git a/web/service/base.ts b/web/service/base.ts index 49377be912..8ffacaa0f1 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -413,7 +413,7 @@ export const ssePost = async ( if (data.code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() } } }) @@ -507,7 +507,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther } = otherOptionsForBaseFetch if (isPublicAPI && code === 'unauthorized') { removeAccessToken() - globalThis.location.reload() + requiredWebSSOLogin() return Promise.reject(err) } if (code === 'init_validate_failed' && IS_CE_EDITION && !silent) { diff --git a/web/service/explore.ts b/web/service/explore.ts index e9e17416d1..6a440d7f5d 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -1,5 +1,6 @@ import { del, get, patch, post } from './base' import type { App, AppCategory } from '@/models/explore' +import type { AccessMode } from '@/models/access-control' export const fetchAppList = () => { return get<{ @@ -39,3 +40,7 @@ export const updatePinStatus = (id: string, isPinned: boolean) => { export const getToolProviders = () => { return get('/workspaces/current/tool-providers') } + +export const getAppAccessModeByAppId = (appId: string) => { + return get<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) +} diff --git a/web/service/share.ts b/web/service/share.ts index 6a2a7e5b16..8c33b85522 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -296,13 +296,6 @@ export const fetchAccessToken = async ({ appCode, userId, webAppAccessToken }: { return get(url, { headers }) as Promise<{ access_token: string }> } -export const getAppAccessMode = (appId: string, isInstalledApp: boolean) => { - if (isInstalledApp) - return consoleGet<{ accessMode: AccessMode }>(`/enterprise/webapp/app/access-mode?appId=${appId}`) - - return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appId=${appId}`) -} - export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => { if (isInstalledApp) return consoleGet<{ result: boolean }>(`/enterprise/webapp/permission?appId=${appId}`) diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts new file mode 100644 index 0000000000..b7d078edbc --- /dev/null +++ b/web/service/use-explore.ts @@ -0,0 +1,81 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import { AccessMode } from '@/models/access-control' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchInstalledAppList, getAppAccessModeByAppId, uninstallApp, updatePinStatus } from './explore' +import { fetchAppMeta, fetchAppParams } from './share' + +const NAME_SPACE = 'explore' + +export const useGetInstalledApps = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'installedApps'], + queryFn: () => { + return fetchInstalledAppList() + }, + }) +} + +export const useUninstallApp = () => { + const client = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'uninstallApp'], + mutationFn: (appId: string) => uninstallApp(appId), + onSuccess: () => { + client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] }) + }, + }) +} + +export const useUpdateAppPinStatus = () => { + const client = useQueryClient() + return useMutation({ + mutationKey: [NAME_SPACE, 'updateAppPinStatus'], + mutationFn: ({ appId, isPinned }: { appId: string; isPinned: boolean }) => updatePinStatus(appId, isPinned), + onSuccess: () => { + client.invalidateQueries({ queryKey: [NAME_SPACE, 'installedApps'] }) + }, + }) +} + +export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + return useQuery({ + queryKey: [NAME_SPACE, 'appAccessMode', appId], + queryFn: () => { + if (systemFeatures.webapp_auth.enabled === false) { + return { + accessMode: AccessMode.PUBLIC, + } + } + if (!appId || appId.length === 0) + return Promise.reject(new Error('App code is required to get access mode')) + + return getAppAccessModeByAppId(appId) + }, + enabled: !!appId, + }) +} + +export const useGetInstalledAppParams = (appId: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appParams', appId], + queryFn: () => { + if (!appId || appId.length === 0) + return Promise.reject(new Error('App ID is required to get app params')) + return fetchAppParams(true, appId) + }, + enabled: !!appId, + }) +} + +export const useGetInstalledAppMeta = (appId: string | null) => { + return useQuery({ + queryKey: [NAME_SPACE, 'appMeta', appId], + queryFn: () => { + if (!appId || appId.length === 0) + return Promise.reject(new Error('App ID is required to get app meta')) + return fetchAppMeta(true, appId) + }, + enabled: !!appId, + }) +} diff --git a/web/service/use-share.ts b/web/service/use-share.ts index b8f96f6cc5..63f18bf0e0 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -1,17 +1,52 @@ +import { useGlobalPublicStore } from '@/context/global-public-context' +import { AccessMode } from '@/models/access-control' import { useQuery } from '@tanstack/react-query' -import { getAppAccessModeByAppCode } from './share' +import { fetchAppInfo, fetchAppMeta, fetchAppParams, getAppAccessModeByAppCode } from './share' const NAME_SPACE = 'webapp' -export const useAppAccessModeByCode = (code: string | null) => { +export const useGetWebAppAccessModeByCode = (code: string | null) => { + const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ queryKey: [NAME_SPACE, 'appAccessMode', code], queryFn: () => { - if (!code) - return null + if (systemFeatures.webapp_auth.enabled === false) { + return { + accessMode: AccessMode.PUBLIC, + } + } + if (!code || code.length === 0) + return Promise.reject(new Error('App code is required to get access mode')) return getAppAccessModeByAppCode(code) }, enabled: !!code, }) } + +export const useGetWebAppInfo = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appInfo'], + queryFn: () => { + return fetchAppInfo() + }, + }) +} + +export const useGetWebAppParams = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appParams'], + queryFn: () => { + return fetchAppParams(false) + }, + }) +} + +export const useGetWebAppMeta = () => { + return useQuery({ + queryKey: [NAME_SPACE, 'appMeta'], + queryFn: () => { + return fetchAppMeta(false) + }, + }) +} From a4f421028c9abe46c05250de61c9fb454a449cc0 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 17 Jul 2025 10:55:59 +0800 Subject: [PATCH 23/91] Feat/change user email (#22213) Co-authored-by: NFish Co-authored-by: JzoNg Co-authored-by: Garfield Dai --- api/.env.example | 2 + api/configs/feature/__init__.py | 19 + api/controllers/console/auth/error.py | 48 +++ api/controllers/console/workspace/account.py | 147 ++++++- api/controllers/console/workspace/members.py | 156 +++++++- api/controllers/console/wraps.py | 26 ++ api/services/account_service.py | 225 +++++++++++ api/services/feature_service.py | 6 +- api/tasks/mail_change_mail_task.py | 78 ++++ api/tasks/mail_owner_transfer_task.py | 152 +++++++ ...hange_mail_confirm_new_template_en-US.html | 125 ++++++ ...hange_mail_confirm_new_template_zh-CN.html | 125 ++++++ ...hange_mail_confirm_old_template_en-US.html | 125 ++++++ ...hange_mail_confirm_old_template_zh-CN.html | 125 ++++++ .../clean_document_job_mail_template-US.html | 152 ++++--- .../invite_member_mail_template_en-US.html | 149 ++++--- .../invite_member_mail_template_zh-CN.html | 147 ++++--- ...space_new_owner_notify_template_en-US.html | 92 +++++ ...space_new_owner_notify_template_zh-CN.html | 92 +++++ ...space_old_owner_notify_template_en-US.html | 122 ++++++ ...space_old_owner_notify_template_zh-CN.html | 122 ++++++ ...orkspace_owner_confirm_template_en-US.html | 153 ++++++++ ...orkspace_owner_confirm_template_zh-CN.html | 153 ++++++++ ...hange_mail_confirm_new_template_en-US.html | 122 ++++++ ...hange_mail_confirm_new_template_zh-CN.html | 122 ++++++ ...hange_mail_confirm_old_template_en-US.html | 122 ++++++ ...hange_mail_confirm_old_template_zh-CN.html | 122 ++++++ .../invite_member_mail_template_en-US.html | 145 ++++--- .../invite_member_mail_template_zh-CN.html | 142 ++++--- ...space_new_owner_notify_template_en-US.html | 89 +++++ ...space_new_owner_notify_template_zh-CN.html | 89 +++++ ...space_old_owner_notify_template_en-US.html | 119 ++++++ ...space_old_owner_notify_template_zh-CN.html | 119 ++++++ ...orkspace_owner_confirm_template_en-US.html | 150 +++++++ ...orkspace_owner_confirm_template_zh-CN.html | 150 +++++++ api/tests/integration_tests/.env.example | 2 + docker/.env.example | 2 + docker/docker-compose.yaml | 2 + .../account-page/email-change-modal.tsx | 371 ++++++++++++++++++ web/app/account/account-page/index.module.css | 9 - web/app/account/account-page/index.tsx | 30 +- web/app/components/billing/type.ts | 3 +- .../account-setting/members-page/index.tsx | 28 +- .../operation/transfer-ownership.tsx | 54 +++ .../transfer-ownership-modal/index.tsx | 253 ++++++++++++ .../member-selector.tsx | 112 ++++++ web/context/provider-context.tsx | 6 + web/i18n/en-US/common.ts | 42 ++ web/i18n/ja-JP/common.ts | 42 ++ web/i18n/zh-Hans/common.ts | 42 ++ web/service/common.ts | 21 + web/types/feature.ts | 2 + 52 files changed, 4726 insertions(+), 327 deletions(-) create mode 100644 api/tasks/mail_change_mail_task.py create mode 100644 api/tasks/mail_owner_transfer_task.py create mode 100644 api/templates/change_mail_confirm_new_template_en-US.html create mode 100644 api/templates/change_mail_confirm_new_template_zh-CN.html create mode 100644 api/templates/change_mail_confirm_old_template_en-US.html create mode 100644 api/templates/change_mail_confirm_old_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_new_owner_notify_template_en-US.html create mode 100644 api/templates/transfer_workspace_new_owner_notify_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_old_owner_notify_template_en-US.html create mode 100644 api/templates/transfer_workspace_old_owner_notify_template_zh-CN.html create mode 100644 api/templates/transfer_workspace_owner_confirm_template_en-US.html create mode 100644 api/templates/transfer_workspace_owner_confirm_template_zh-CN.html create mode 100644 api/templates/without-brand/change_mail_confirm_new_template_en-US.html create mode 100644 api/templates/without-brand/change_mail_confirm_new_template_zh-CN.html create mode 100644 api/templates/without-brand/change_mail_confirm_old_template_en-US.html create mode 100644 api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_new_owner_notify_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_old_owner_notify_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html create mode 100644 api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html create mode 100644 api/templates/without-brand/transfer_workspace_owner_confirm_template_zh-CN.html create mode 100644 web/app/account/account-page/email-change-modal.tsx delete mode 100644 web/app/account/account-page/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx diff --git a/api/.env.example b/api/.env.example index c09c6c230e..3fe95c44b5 100644 --- a/api/.env.example +++ b/api/.env.example @@ -495,6 +495,8 @@ ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} # Reset password token expiry minutes RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 CREATE_TIDB_SERVICE_JOB_ENABLED=false diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index f6a8b037ca..f1d529355d 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -31,6 +31,15 @@ class SecurityConfig(BaseSettings): description="Duration in minutes for which a password reset token remains valid", default=5, ) + CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a change email token remains valid", + default=5, + ) + + OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: PositiveInt = Field( + description="Duration in minutes for which a owner transfer token remains valid", + default=5, + ) LOGIN_DISABLED: bool = Field( description="Whether to disable login checks", @@ -614,6 +623,16 @@ class AuthConfig(BaseSettings): default=86400, ) + CHANGE_EMAIL_LOCKOUT_DURATION: PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying change email after exceeding the rate limit.", + default=86400, + ) + + OWNER_TRANSFER_LOCKOUT_DURATION: PositiveInt = Field( + description="Time (in seconds) a user must wait before retrying owner transfer after exceeding the rate limit.", + default=86400, + ) + class ModerationConfig(BaseSettings): """ diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index b40934dbf5..f4a8b97483 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -31,6 +31,18 @@ class PasswordResetRateLimitExceededError(BaseHTTPException): code = 429 +class EmailChangeRateLimitExceededError(BaseHTTPException): + error_code = "email_change_rate_limit_exceeded" + description = "Too many email change emails have been sent. Please try again in 1 minutes." + code = 429 + + +class OwnerTransferRateLimitExceededError(BaseHTTPException): + error_code = "owner_transfer_rate_limit_exceeded" + description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes." + code = 429 + + class EmailCodeError(BaseHTTPException): error_code = "email_code_error" description = "Email code is invalid or expired." @@ -65,3 +77,39 @@ class EmailPasswordResetLimitError(BaseHTTPException): error_code = "email_password_reset_limit" description = "Too many failed password reset attempts. Please try again in 24 hours." code = 429 + + +class EmailChangeLimitError(BaseHTTPException): + error_code = "email_change_limit" + description = "Too many failed email change attempts. Please try again in 24 hours." + code = 429 + + +class EmailAlreadyInUseError(BaseHTTPException): + error_code = "email_already_in_use" + description = "A user with this email already exists." + code = 400 + + +class OwnerTransferLimitError(BaseHTTPException): + error_code = "owner_transfer_limit" + description = "Too many failed owner transfer attempts. Please try again in 24 hours." + code = 429 + + +class NotOwnerError(BaseHTTPException): + error_code = "not_owner" + description = "You are not the owner of the workspace." + code = 400 + + +class CannotTransferOwnerToSelfError(BaseHTTPException): + error_code = "cannot_transfer_owner_to_self" + description = "You cannot transfer ownership to yourself." + code = 400 + + +class MemberNotInTenantError(BaseHTTPException): + error_code = "member_not_in_tenant" + description = "The member is not in the workspace." + code = 400 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index a9dbf44456..1f22e3fd01 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -4,10 +4,20 @@ import pytz from flask import request from flask_login import current_user from flask_restful import Resource, fields, marshal_with, reqparse +from sqlalchemy import select +from sqlalchemy.orm import Session from configs import dify_config from constants.languages import supported_language from controllers.console import api +from controllers.console.auth.error import ( + EmailAlreadyInUseError, + EmailChangeLimitError, + EmailCodeError, + InvalidEmailError, + InvalidTokenError, +) +from controllers.console.error import AccountNotFound, EmailSendIpLimitError from controllers.console.workspace.error import ( AccountAlreadyInitedError, CurrentPasswordIncorrectError, @@ -18,15 +28,17 @@ from controllers.console.workspace.error import ( from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_enabled, + enable_change_email, enterprise_license_required, only_edition_cloud, setup_required, ) from extensions.ext_database import db from fields.member_fields import account_fields -from libs.helper import TimestampField, timezone +from libs.helper import TimestampField, email, extract_remote_ip, timezone from libs.login import login_required from models import AccountIntegrate, InvitationCode +from models.account import Account from services.account_service import AccountService from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError @@ -369,6 +381,134 @@ class EducationAutoCompleteApi(Resource): return BillingService.EducationIdentity.autocomplete(args["keywords"], args["page"], args["limit"]) +class ChangeEmailSendEmailApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + parser.add_argument("language", type=str, required=False, location="json") + parser.add_argument("phase", type=str, required=False, location="json") + parser.add_argument("token", type=str, required=False, location="json") + args = parser.parse_args() + + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + account = None + user_email = args["email"] + if args["phase"] is not None and args["phase"] == "new_email": + if args["token"] is None: + raise InvalidTokenError() + + reset_data = AccountService.get_change_email_data(args["token"]) + if reset_data is None: + raise InvalidTokenError() + user_email = reset_data.get("email", "") + + if user_email != current_user.email: + raise InvalidEmailError() + else: + with Session(db.engine) as session: + account = session.execute(select(Account).filter_by(email=args["email"])).scalar_one_or_none() + if account is None: + raise AccountNotFound() + + token = AccountService.send_change_email_email( + account=account, email=args["email"], old_email=user_email, language=language, phase=args["phase"] + ) + return {"result": "success", "data": token} + + +class ChangeEmailCheckApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + user_email = args["email"] + + is_change_email_error_rate_limit = AccountService.is_change_email_error_rate_limit(args["email"]) + if is_change_email_error_rate_limit: + raise EmailChangeLimitError() + + token_data = AccountService.get_change_email_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_change_email_error_rate_limit(args["email"]) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_change_email_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_change_email_token( + user_email, code=args["code"], old_email=token_data.get("old_email"), additional_data={} + ) + + AccountService.reset_change_email_error_rate_limit(args["email"]) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + + +class ChangeEmailResetApi(Resource): + @enable_change_email + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("new_email", type=email, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + reset_data = AccountService.get_change_email_data(args["token"]) + if not reset_data: + raise InvalidTokenError() + + AccountService.revoke_change_email_token(args["token"]) + + if not AccountService.check_email_unique(args["new_email"]): + raise EmailAlreadyInUseError() + + old_email = reset_data.get("old_email", "") + if current_user.email != old_email: + raise AccountNotFound() + + updated_account = AccountService.update_account(current_user, email=args["new_email"]) + + return updated_account + + +class CheckEmailUnique(Resource): + @setup_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("email", type=email, required=True, location="json") + args = parser.parse_args() + if not AccountService.check_email_unique(args["email"]): + raise EmailAlreadyInUseError() + return {"result": "success"} + + # Register API resources api.add_resource(AccountInitApi, "/account/init") api.add_resource(AccountProfileApi, "/account/profile") @@ -385,5 +525,10 @@ api.add_resource(AccountDeleteUpdateFeedbackApi, "/account/delete/feedback") api.add_resource(EducationVerifyApi, "/account/education/verify") api.add_resource(EducationApi, "/account/education") api.add_resource(EducationAutoCompleteApi, "/account/education/autocomplete") +# Change email +api.add_resource(ChangeEmailSendEmailApi, "/account/change-email") +api.add_resource(ChangeEmailCheckApi, "/account/change-email/validity") +api.add_resource(ChangeEmailResetApi, "/account/change-email/reset") +api.add_resource(CheckEmailUnique, "/account/change-email/check-email-unique") # api.add_resource(AccountEmailApi, '/account/email') # api.add_resource(AccountEmailVerifyApi, '/account/email-verify') diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 48225ac90d..30a4148dbb 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,22 +1,34 @@ from urllib import parse +from flask import request from flask_login import current_user from flask_restful import Resource, abort, marshal_with, reqparse import services from configs import dify_config from controllers.console import api -from controllers.console.error import WorkspaceMembersLimitExceeded +from controllers.console.auth.error import ( + CannotTransferOwnerToSelfError, + EmailCodeError, + InvalidEmailError, + InvalidTokenError, + MemberNotInTenantError, + NotOwnerError, + OwnerTransferLimitError, +) +from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, + is_allow_transfer_owner, setup_required, ) from extensions.ext_database import db from fields.member_fields import account_with_role_list_fields +from libs.helper import extract_remote_ip from libs.login import login_required from models.account import Account, TenantAccountRole -from services.account_service import RegisterService, TenantService +from services.account_service import AccountService, RegisterService, TenantService from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService @@ -156,8 +168,148 @@ class DatasetOperatorMemberListApi(Resource): return {"result": "success", "accounts": members}, 200 +class SendOwnerTransferEmailApi(Resource): + """Send owner transfer email.""" + + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("language", type=str, required=False, location="json") + args = parser.parse_args() + ip_address = extract_remote_ip(request) + if AccountService.is_email_send_ip_limit(ip_address): + raise EmailSendIpLimitError() + + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + if args["language"] is not None and args["language"] == "zh-Hans": + language = "zh-Hans" + else: + language = "en-US" + + email = current_user.email + + token = AccountService.send_owner_transfer_email( + account=current_user, + email=email, + language=language, + workspace_name=current_user.current_tenant.name, + ) + + return {"result": "success", "data": token} + + +class OwnerTransferCheckApi(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self): + parser = reqparse.RequestParser() + parser.add_argument("code", type=str, required=True, location="json") + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + user_email = current_user.email + + is_owner_transfer_error_rate_limit = AccountService.is_owner_transfer_error_rate_limit(user_email) + if is_owner_transfer_error_rate_limit: + raise OwnerTransferLimitError() + + token_data = AccountService.get_owner_transfer_data(args["token"]) + if token_data is None: + raise InvalidTokenError() + + if user_email != token_data.get("email"): + raise InvalidEmailError() + + if args["code"] != token_data.get("code"): + AccountService.add_owner_transfer_error_rate_limit(user_email) + raise EmailCodeError() + + # Verified, revoke the first token + AccountService.revoke_owner_transfer_token(args["token"]) + + # Refresh token data by generating a new token + _, new_token = AccountService.generate_owner_transfer_token(user_email, code=args["code"], additional_data={}) + + AccountService.reset_owner_transfer_error_rate_limit(user_email) + return {"is_valid": True, "email": token_data.get("email"), "token": new_token} + + +class OwnerTransfer(Resource): + @setup_required + @login_required + @account_initialization_required + @is_allow_transfer_owner + def post(self, member_id): + parser = reqparse.RequestParser() + parser.add_argument("token", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + # check if the current user is the owner of the workspace + if not TenantService.is_owner(current_user, current_user.current_tenant): + raise NotOwnerError() + + if current_user.id == str(member_id): + raise CannotTransferOwnerToSelfError() + + transfer_token_data = AccountService.get_owner_transfer_data(args["token"]) + if not transfer_token_data: + print(transfer_token_data, "transfer_token_data") + raise InvalidTokenError() + + if transfer_token_data.get("email") != current_user.email: + print(transfer_token_data.get("email"), current_user.email) + raise InvalidEmailError() + + AccountService.revoke_owner_transfer_token(args["token"]) + + member = db.session.get(Account, str(member_id)) + if not member: + abort(404) + else: + member_account = member + if not TenantService.is_member(member_account, current_user.current_tenant): + raise MemberNotInTenantError() + + try: + assert member is not None, "Member not found" + TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user) + + AccountService.send_new_owner_transfer_notify_email( + account=member, + email=member.email, + workspace_name=current_user.current_tenant.name, + ) + + AccountService.send_old_owner_transfer_notify_email( + account=current_user, + email=current_user.email, + workspace_name=current_user.current_tenant.name, + new_owner_email=member.email, + ) + + except Exception as e: + raise ValueError(str(e)) + + return {"result": "success"} + + api.add_resource(MemberListApi, "/workspaces/current/members") api.add_resource(MemberInviteEmailApi, "/workspaces/current/members/invite-email") api.add_resource(MemberCancelInviteApi, "/workspaces/current/members/") api.add_resource(MemberUpdateRoleApi, "/workspaces/current/members//update-role") api.add_resource(DatasetOperatorMemberListApi, "/workspaces/current/dataset-operators") +# owner transfer +api.add_resource(SendOwnerTransferEmailApi, "/workspaces/current/members/send-owner-transfer-confirm-email") +api.add_resource(OwnerTransferCheckApi, "/workspaces/current/members/owner-transfer-check") +api.add_resource(OwnerTransfer, "/workspaces/current/members//owner-transfer") diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index ca122772de..d862dac373 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -235,3 +235,29 @@ def email_password_login_enabled(view): abort(403) return decorated + + +def enable_change_email(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_system_features() + if features.enable_change_email: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated + + +def is_allow_transfer_owner(view): + @wraps(view) + def decorated(*args, **kwargs): + features = FeatureService.get_features(current_user.current_tenant_id) + if features.is_allow_transfer_workspace: + return view(*args, **kwargs) + + # otherwise, return 403 + abort(403) + + return decorated diff --git a/api/services/account_service.py b/api/services/account_service.py index 2ba6f4345b..4d5366f47f 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -52,8 +52,14 @@ from services.errors.workspace import WorkSpaceNotAllowedCreateError, Workspaces from services.feature_service import FeatureService from tasks.delete_account_task import delete_account_task from tasks.mail_account_deletion_task import send_account_deletion_verification_code +from tasks.mail_change_mail_task import send_change_mail_task from tasks.mail_email_code_login import send_email_code_login_mail_task from tasks.mail_invite_member_task import send_invite_member_mail_task +from tasks.mail_owner_transfer_task import ( + send_new_owner_transfer_notify_email_task, + send_old_owner_transfer_notify_email_task, + send_owner_transfer_confirm_task, +) from tasks.mail_reset_password_task import send_reset_password_mail_task @@ -75,8 +81,13 @@ class AccountService: email_code_account_deletion_rate_limiter = RateLimiter( prefix="email_code_account_deletion_rate_limit", max_attempts=1, time_window=60 * 1 ) + change_email_rate_limiter = RateLimiter(prefix="change_email_rate_limit", max_attempts=1, time_window=60 * 1) + owner_transfer_rate_limiter = RateLimiter(prefix="owner_transfer_rate_limit", max_attempts=1, time_window=60 * 1) + LOGIN_MAX_ERROR_LIMITS = 5 FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5 + CHANGE_EMAIL_MAX_ERROR_LIMITS = 5 + OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: @@ -419,6 +430,101 @@ class AccountService: cls.reset_password_rate_limiter.increment_rate_limit(account_email) return token + @classmethod + def send_change_email_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + old_email: Optional[str] = None, + language: Optional[str] = "en-US", + phase: Optional[str] = None, + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.change_email_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import EmailChangeRateLimitExceededError + + raise EmailChangeRateLimitExceededError() + + code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) + + send_change_mail_task.delay( + language=language, + to=account_email, + code=code, + phase=phase, + ) + cls.change_email_rate_limiter.increment_rate_limit(account_email) + return token + + @classmethod + def send_owner_transfer_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + if cls.owner_transfer_rate_limiter.is_rate_limited(account_email): + from controllers.console.auth.error import OwnerTransferRateLimitExceededError + + raise OwnerTransferRateLimitExceededError() + + code, token = cls.generate_owner_transfer_token(account_email, account) + + send_owner_transfer_confirm_task.delay( + language=language, + to=account_email, + code=code, + workspace=workspace_name, + ) + cls.owner_transfer_rate_limiter.increment_rate_limit(account_email) + return token + + @classmethod + def send_old_owner_transfer_notify_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + new_owner_email: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + send_old_owner_transfer_notify_email_task.delay( + language=language, + to=account_email, + workspace=workspace_name, + new_owner_email=new_owner_email, + ) + + @classmethod + def send_new_owner_transfer_notify_email( + cls, + account: Optional[Account] = None, + email: Optional[str] = None, + language: Optional[str] = "en-US", + workspace_name: Optional[str] = "", + ): + account_email = account.email if account else email + if account_email is None: + raise ValueError("Email must be provided.") + + send_new_owner_transfer_notify_email_task.delay( + language=language, + to=account_email, + workspace=workspace_name, + ) + @classmethod def generate_reset_password_token( cls, @@ -435,14 +541,64 @@ class AccountService: ) return code, token + @classmethod + def generate_change_email_token( + cls, + email: str, + account: Optional[Account] = None, + code: Optional[str] = None, + old_email: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + additional_data["old_email"] = old_email + token = TokenManager.generate_token( + account=account, email=email, token_type="change_email", additional_data=additional_data + ) + return code, token + + @classmethod + def generate_owner_transfer_token( + cls, + email: str, + account: Optional[Account] = None, + code: Optional[str] = None, + additional_data: dict[str, Any] = {}, + ): + if not code: + code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)]) + additional_data["code"] = code + token = TokenManager.generate_token( + account=account, email=email, token_type="owner_transfer", additional_data=additional_data + ) + return code, token + @classmethod def revoke_reset_password_token(cls, token: str): TokenManager.revoke_token(token, "reset_password") + @classmethod + def revoke_change_email_token(cls, token: str): + TokenManager.revoke_token(token, "change_email") + + @classmethod + def revoke_owner_transfer_token(cls, token: str): + TokenManager.revoke_token(token, "owner_transfer") + @classmethod def get_reset_password_data(cls, token: str) -> Optional[dict[str, Any]]: return TokenManager.get_token_data(token, "reset_password") + @classmethod + def get_change_email_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "change_email") + + @classmethod + def get_owner_transfer_data(cls, token: str) -> Optional[dict[str, Any]]: + return TokenManager.get_token_data(token, "owner_transfer") + @classmethod def send_email_code_login_email( cls, account: Optional[Account] = None, email: Optional[str] = None, language: Optional[str] = "en-US" @@ -552,6 +708,62 @@ class AccountService: key = f"forgot_password_error_rate_limit:{email}" redis_client.delete(key) + @staticmethod + @redis_fallback(default_return=None) + def add_change_email_error_rate_limit(email: str) -> None: + key = f"change_email_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.CHANGE_EMAIL_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_change_email_error_rate_limit(email: str) -> bool: + key = f"change_email_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.CHANGE_EMAIL_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_change_email_error_rate_limit(email: str): + key = f"change_email_error_rate_limit:{email}" + redis_client.delete(key) + + @staticmethod + @redis_fallback(default_return=None) + def add_owner_transfer_error_rate_limit(email: str) -> None: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + count = 0 + count = int(count) + 1 + redis_client.setex(key, dify_config.OWNER_TRANSFER_LOCKOUT_DURATION, count) + + @staticmethod + @redis_fallback(default_return=False) + def is_owner_transfer_error_rate_limit(email: str) -> bool: + key = f"owner_transfer_error_rate_limit:{email}" + count = redis_client.get(key) + if count is None: + return False + count = int(count) + if count > AccountService.OWNER_TRANSFER_MAX_ERROR_LIMITS: + return True + return False + + @staticmethod + @redis_fallback(default_return=None) + def reset_owner_transfer_error_rate_limit(email: str): + key = f"owner_transfer_error_rate_limit:{email}" + redis_client.delete(key) + @staticmethod @redis_fallback(default_return=False) def is_email_send_ip_limit(ip_address: str): @@ -593,6 +805,10 @@ class AccountService: return False + @staticmethod + def check_email_unique(email: str) -> bool: + return db.session.query(Account).filter_by(email=email).first() is None + class TenantService: @staticmethod @@ -865,6 +1081,15 @@ class TenantService: return cast(dict, tenant.custom_config_dict) + @staticmethod + def is_owner(account: Account, tenant: Tenant) -> bool: + return TenantService.get_user_role(account, tenant) == TenantAccountRole.OWNER + + @staticmethod + def is_member(account: Account, tenant: Tenant) -> bool: + """Check if the account is a member of the tenant""" + return TenantService.get_user_role(account, tenant) is not None + class RegisterService: @classmethod diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 188caf3505..1441e6ce16 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -123,7 +123,7 @@ class FeatureModel(BaseModel): dataset_operator_enabled: bool = False webapp_copyright_enabled: bool = False workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0) - + is_allow_transfer_workspace: bool = True # pydantic configs model_config = ConfigDict(protected_namespaces=()) @@ -149,6 +149,7 @@ class SystemFeatureModel(BaseModel): branding: BrandingModel = BrandingModel() webapp_auth: WebAppAuthModel = WebAppAuthModel() plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() + enable_change_email: bool = True class FeatureService: @@ -186,6 +187,7 @@ class FeatureService: if dify_config.ENTERPRISE_ENABLED: system_features.branding.enabled = True system_features.webapp_auth.enabled = True + system_features.enable_change_email = False cls._fulfill_params_from_enterprise(system_features) if dify_config.MARKETPLACE_ENABLED: @@ -228,6 +230,8 @@ class FeatureService: if features.billing.subscription.plan != "sandbox": features.webapp_copyright_enabled = True + else: + features.is_allow_transfer_workspace = False if "members" in billing_info: features.members.size = billing_info["members"]["size"] diff --git a/api/tasks/mail_change_mail_task.py b/api/tasks/mail_change_mail_task.py new file mode 100644 index 0000000000..da44040b7d --- /dev/null +++ b/api/tasks/mail_change_mail_task.py @@ -0,0 +1,78 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_change_mail_task(language: str, to: str, code: str, phase: str): + """ + Async Send change email mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param phase: Change email phase (new_email, old_email) + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + + email_config = { + "zh-Hans": { + "old_email": { + "subject": "检测您现在的邮箱", + "template_with_brand": "change_mail_confirm_old_template_zh-CN.html", + "template_without_brand": "without-brand/change_mail_confirm_old_template_zh-CN.html", + }, + "new_email": { + "subject": "确认您的邮箱地址变更", + "template_with_brand": "change_mail_confirm_new_template_zh-CN.html", + "template_without_brand": "without-brand/change_mail_confirm_new_template_zh-CN.html", + }, + }, + "en": { + "old_email": { + "subject": "Check your current email", + "template_with_brand": "change_mail_confirm_old_template_en-US.html", + "template_without_brand": "without-brand/change_mail_confirm_old_template_en-US.html", + }, + "new_email": { + "subject": "Confirm your new email address", + "template_with_brand": "change_mail_confirm_new_template_en-US.html", + "template_without_brand": "without-brand/change_mail_confirm_new_template_en-US.html", + }, + }, + } + + # send change email mail using different languages + try: + system_features = FeatureService.get_system_features() + lang_key = "zh-Hans" if language == "zh-Hans" else "en" + + if phase not in ["old_email", "new_email"]: + raise ValueError("Invalid phase") + + config = email_config[lang_key][phase] + subject = config["subject"] + + if system_features.branding.enabled: + template = config["template_without_brand"] + else: + template = config["template_with_brand"] + + html_content = render_template(template, to=to, code=code) + mail.send(to=to, subject=subject, html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style("Send change email mail to {} succeeded: latency: {}".format(to, end_at - start_at), fg="green") + ) + except Exception: + logging.exception("Send change email mail to {} failed".format(to)) diff --git a/api/tasks/mail_owner_transfer_task.py b/api/tasks/mail_owner_transfer_task.py new file mode 100644 index 0000000000..8d05c6dc0f --- /dev/null +++ b/api/tasks/mail_owner_transfer_task.py @@ -0,0 +1,152 @@ +import logging +import time + +import click +from celery import shared_task # type: ignore +from flask import render_template + +from extensions.ext_mail import mail +from services.feature_service import FeatureService + + +@shared_task(queue="mail") +def send_owner_transfer_confirm_task(language: str, to: str, code: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_owner_confirm_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_zh-CN.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="验证您转移工作空间所有权的请求", html=html_content) + else: + template = "transfer_workspace_owner_confirm_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_owner_confirm_template_en-US.html" + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + else: + html_content = render_template(template, to=to, code=code, WorkspaceName=workspace) + mail.send(to=to, subject="Verify Your Request to Transfer Workspace Ownership", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_old_owner_transfer_notify_email_task(language: str, to: str, workspace: str, new_owner_email: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param workspace: Workspace name + :param new_owner_email: New owner email + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_old_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="工作区所有权已转移", html=html_content) + else: + template = "transfer_workspace_old_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_old_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace, NewOwnerEmail=new_owner_email) + mail.send(to=to, subject="Workspace ownership has been transferred", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) + + +@shared_task(queue="mail") +def send_new_owner_transfer_notify_email_task(language: str, to: str, workspace: str): + """ + Async Send owner transfer confirm mail + :param language: Language in which the email should be sent (e.g., 'en', 'zh') + :param to: Recipient email address + :param code: Change email code + :param workspace: Workspace name + """ + if not mail.is_inited(): + return + + logging.info(click.style("Start change email mail to {}".format(to), fg="green")) + start_at = time.perf_counter() + # send change email mail using different languages + try: + if language == "zh-Hans": + template = "transfer_workspace_new_owner_notify_template_zh-CN.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_zh-CN.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"您现在是 {workspace} 的所有者", html=html_content) + else: + template = "transfer_workspace_new_owner_notify_template_en-US.html" + system_features = FeatureService.get_system_features() + if system_features.branding.enabled: + template = "without-brand/transfer_workspace_new_owner_notify_template_en-US.html" + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + else: + html_content = render_template(template, to=to, WorkspaceName=workspace) + mail.send(to=to, subject=f"You are now the owner of {workspace}", html=html_content) + + end_at = time.perf_counter() + logging.info( + click.style( + "Send owner transfer confirm mail to {} succeeded: latency: {}".format(to, end_at - start_at), + fg="green", + ) + ) + except Exception: + logging.exception("owner transfer confirm email mail to {} failed".format(to)) diff --git a/api/templates/change_mail_confirm_new_template_en-US.html b/api/templates/change_mail_confirm_new_template_en-US.html new file mode 100644 index 0000000000..88721e787c --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_en-US.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Confirm Your New Email Address

+
+

You’re updating the email address linked to your Dify account.

+

To confirm this action, please use the verification code below.

+

This code will only be valid for the next 5 minutes:

+
+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_new_template_zh-CN.html b/api/templates/change_mail_confirm_new_template_zh-CN.html new file mode 100644 index 0000000000..25336ea1a1 --- /dev/null +++ b/api/templates/change_mail_confirm_new_template_zh-CN.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

确认您的邮箱地址变更

+
+

您正在更新与您的 Dify 账户关联的邮箱地址。

+

为了确认此操作,请使用以下验证码。

+

此验证码仅在接下来的5分钟内有效:

+
+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_en-US.html b/api/templates/change_mail_confirm_old_template_en-US.html new file mode 100644 index 0000000000..b20306aa87 --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_en-US.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

Verify Your Request to Change Email

+
+

We received a request to change the email address associated with your Dify account.

+

To confirm this action, please use the verification code below.

+

This code will only be valid for the next 5 minutes:

+
+
+ {{code}} +
+

If you didn’t make this request, please ignore this email or contact support immediately.

+
+ + + + diff --git a/api/templates/change_mail_confirm_old_template_zh-CN.html b/api/templates/change_mail_confirm_old_template_zh-CN.html new file mode 100644 index 0000000000..4a3e35cfb6 --- /dev/null +++ b/api/templates/change_mail_confirm_old_template_zh-CN.html @@ -0,0 +1,125 @@ + + + + + + + + +
+
+ + Dify Logo +
+

验证您的邮箱变更请求

+
+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

+

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

+

此验证码仅在接下来的5分钟内有效:

+
+
+ {{code}} +
+

如果您没有请求变更邮箱地址,请忽略此邮件或立即联系支持。

+
+ + + + diff --git a/api/templates/clean_document_job_mail_template-US.html b/api/templates/clean_document_job_mail_template-US.html index 2d8f78b46a..b26e494f80 100644 --- a/api/templates/clean_document_job_mail_template-US.html +++ b/api/templates/clean_document_job_mail_template-US.html @@ -6,94 +6,136 @@ Documents Disabled Notification -