From b2aa385942a74f7cba5cfa2e32e9027ca5860bd5 Mon Sep 17 00:00:00 2001 From: ice yao Date: Fri, 18 Oct 2024 20:08:41 +0800 Subject: [PATCH 01/11] feat: Add volcengine tos storage test (#9495) --- api/tests/unit_tests/oss/__mock/__init__.py | 0 .../unit_tests/oss/__mock/volcengine_tos.py | 100 ++++++++++++++++++ .../unit_tests/oss/volcengine_tos/__init__.py | 0 .../oss/volcengine_tos/test_volcengine_tos.py | 67 ++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 api/tests/unit_tests/oss/__mock/__init__.py create mode 100644 api/tests/unit_tests/oss/__mock/volcengine_tos.py create mode 100644 api/tests/unit_tests/oss/volcengine_tos/__init__.py create mode 100644 api/tests/unit_tests/oss/volcengine_tos/test_volcengine_tos.py diff --git a/api/tests/unit_tests/oss/__mock/__init__.py b/api/tests/unit_tests/oss/__mock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/oss/__mock/volcengine_tos.py b/api/tests/unit_tests/oss/__mock/volcengine_tos.py new file mode 100644 index 0000000000..241764c521 --- /dev/null +++ b/api/tests/unit_tests/oss/__mock/volcengine_tos.py @@ -0,0 +1,100 @@ +import os +from typing import Union +from unittest.mock import MagicMock + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from tos import TosClientV2 +from tos.clientv2 import DeleteObjectOutput, GetObjectOutput, HeadObjectOutput, PutObjectOutput + + +class AttrDict(dict): + def __getattr__(self, item): + return self.get(item) + + +def get_example_bucket() -> str: + return "dify" + + +def get_example_filename() -> str: + return "test.txt" + + +def get_example_data() -> bytes: + return b"test" + + +def get_example_filepath() -> str: + return "/test" + + +class MockVolcengineTosClass: + def __init__(self, ak="", sk="", endpoint="", region=""): + self.bucket_name = get_example_bucket() + self.key = get_example_filename() + self.content = get_example_data() + self.filepath = get_example_filepath() + self.resp = AttrDict( + { + "x-tos-server-side-encryption": "kms", + "x-tos-server-side-encryption-kms-key-id": "trn:kms:cn-beijing:****:keyrings/ring-test/keys/key-test", + "x-tos-server-side-encryption-customer-algorithm": "AES256", + "x-tos-version-id": "test", + "x-tos-hash-crc64ecma": 123456, + "request_id": "test", + "headers": { + "x-tos-id-2": "test", + "ETag": "123456", + }, + "status": 200, + } + ) + + def put_object(self, bucket: str, key: str, content=None) -> PutObjectOutput: + assert bucket == self.bucket_name + assert key == self.key + assert content == self.content + return PutObjectOutput(self.resp) + + def get_object(self, bucket: str, key: str) -> GetObjectOutput: + assert bucket == self.bucket_name + assert key == self.key + + get_object_output = MagicMock(GetObjectOutput) + get_object_output.read.return_value = self.content + return get_object_output + + def get_object_to_file(self, bucket: str, key: str, file_path: str): + assert bucket == self.bucket_name + assert key == self.key + assert file_path == self.filepath + + def head_object(self, bucket: str, key: str) -> HeadObjectOutput: + assert bucket == self.bucket_name + assert key == self.key + return HeadObjectOutput(self.resp) + + def delete_object(self, bucket: str, key: str): + assert bucket == self.bucket_name + assert key == self.key + return DeleteObjectOutput(self.resp) + + +MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" + + +@pytest.fixture +def setup_volcengine_tos_mock(monkeypatch: MonkeyPatch): + if MOCK: + monkeypatch.setattr(TosClientV2, "__init__", MockVolcengineTosClass.__init__) + monkeypatch.setattr(TosClientV2, "put_object", MockVolcengineTosClass.put_object) + monkeypatch.setattr(TosClientV2, "get_object", MockVolcengineTosClass.get_object) + monkeypatch.setattr(TosClientV2, "get_object_to_file", MockVolcengineTosClass.get_object_to_file) + monkeypatch.setattr(TosClientV2, "head_object", MockVolcengineTosClass.head_object) + monkeypatch.setattr(TosClientV2, "delete_object", MockVolcengineTosClass.delete_object) + + yield + + if MOCK: + monkeypatch.undo() diff --git a/api/tests/unit_tests/oss/volcengine_tos/__init__.py b/api/tests/unit_tests/oss/volcengine_tos/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/oss/volcengine_tos/test_volcengine_tos.py b/api/tests/unit_tests/oss/volcengine_tos/test_volcengine_tos.py new file mode 100644 index 0000000000..3f334a3764 --- /dev/null +++ b/api/tests/unit_tests/oss/volcengine_tos/test_volcengine_tos.py @@ -0,0 +1,67 @@ +from collections.abc import Generator + +from flask import Flask +from tos import TosClientV2 +from tos.clientv2 import GetObjectOutput, HeadObjectOutput, PutObjectOutput + +from extensions.storage.volcengine_tos_storage import VolcengineTosStorage +from tests.unit_tests.oss.__mock.volcengine_tos import ( + get_example_bucket, + get_example_data, + get_example_filename, + get_example_filepath, + setup_volcengine_tos_mock, +) + + +class VolcengineTosTest: + _instance = None + + def __new__(cls): + if cls._instance == None: + cls._instance = object.__new__(cls) + return cls._instance + else: + return cls._instance + + def __init__(self): + self.storage = VolcengineTosStorage(app=Flask(__name__)) + self.storage.bucket_name = get_example_bucket() + self.storage.client = TosClientV2( + ak="dify", + sk="dify", + endpoint="https://xxx.volces.com", + region="cn-beijing", + ) + + +def test_save(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + volc_tos.storage.save(get_example_filename(), get_example_data()) + + +def test_load_once(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + assert volc_tos.storage.load_once(get_example_filename()) == get_example_data() + + +def test_load_stream(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + generator = volc_tos.storage.load_stream(get_example_filename()) + assert isinstance(generator, Generator) + assert next(generator) == get_example_data() + + +def test_download(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + volc_tos.storage.download(get_example_filename(), get_example_filepath()) + + +def test_exists(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + assert volc_tos.storage.exists(get_example_filename()) + + +def test_delete(setup_volcengine_tos_mock): + volc_tos = VolcengineTosTest() + volc_tos.storage.delete(get_example_filename()) From a9fc85027d31d76cd8714d6d2633c208cc2f2dcb Mon Sep 17 00:00:00 2001 From: Zven Date: Fri, 18 Oct 2024 20:22:57 +0800 Subject: [PATCH 02/11] chore: update the description for storage_type (#9492) --- api/.env.example | 2 +- api/configs/middleware/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/.env.example b/api/.env.example index aa155003ab..a7f5206088 100644 --- a/api/.env.example +++ b/api/.env.example @@ -42,7 +42,7 @@ DB_DATABASE=dify # Storage configuration # use for store upload files, private keys... -# storage type: local, s3, azure-blob, google-storage, tencent-cos, huawei-obs, volcengine-tos, baidu-obs, supabase +# storage type: local, s3, aliyun-oss, azure-blob, baidu-obs, google-storage, huawei-obs, oci-storage, tencent-cos, volcengine-tos, supabase STORAGE_TYPE=local STORAGE_LOCAL_PATH=storage S3_USE_AWS_MANAGED_IAM=false diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index fa7f41d630..84d03e2f45 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -35,7 +35,8 @@ from configs.middleware.vdb.weaviate_config import WeaviateConfig class StorageConfig(BaseSettings): STORAGE_TYPE: str = Field( description="Type of storage to use." - " Options: 'local', 's3', 'azure-blob', 'aliyun-oss', 'google-storage'. Default is 'local'.", + " Options: 'local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', 'google-storage', 'huawei-obs', " + "'oci-storage', 'tencent-cos', 'volcengine-tos', 'supabase'. Default is 'local'.", default="local", ) From ce476f2e5c9df876d2f4ca166e1464a9451f4214 Mon Sep 17 00:00:00 2001 From: Chenhe Gu Date: Sat, 19 Oct 2024 09:58:22 +0800 Subject: [PATCH 03/11] refine wording in license (#9505) --- LICENSE | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 06b0fa1d12..d7b8373839 100644 --- a/LICENSE +++ b/LICENSE @@ -6,8 +6,9 @@ Dify is licensed under the Apache License 2.0, with the following additional con a. Multi-tenant service: Unless explicitly authorized by Dify in writing, you may not use the Dify source code to operate a multi-tenant environment. - Tenant Definition: Within the context of Dify, one tenant corresponds to one workspace. The workspace provides a separated area for each tenant's data and configurations. - -b. LOGO and copyright information: In the process of using Dify's frontend components, you may not remove or modify the LOGO or copyright information in the Dify console or applications. This restriction is inapplicable to uses of Dify that do not involve its frontend components. + +b. LOGO and copyright information: In the process of using Dify's frontend, you may not remove or modify the LOGO or copyright information in the Dify console or applications. This restriction is inapplicable to uses of Dify that do not involve its frontend. + - Frontend Definition: For the purposes of this license, the "frontend" of Dify includes all components located in the `web/` directory when running Dify from the raw source code, or the "web" image when running Dify with Docker. Please contact business@dify.ai by email to inquire about licensing matters. From c71af7f610c84fa989997cd0b938146bdf705b21 Mon Sep 17 00:00:00 2001 From: zhuhao <37029601+hwzhuhao@users.noreply.github.com> Date: Sat, 19 Oct 2024 09:58:50 +0800 Subject: [PATCH 04/11] fix: resolve the error of docker-compose startup when the storage is baidu-obs (#9502) --- docker/docker-compose.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 065c5e92bc..a3ea7c6059 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -96,6 +96,10 @@ x-shared-env: &shared-api-worker-env VOLCENGINE_TOS_ACCESS_KEY: ${VOLCENGINE_TOS_ACCESS_KEY:-} VOLCENGINE_TOS_ENDPOINT: ${VOLCENGINE_TOS_ENDPOINT:-} VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-} + BAIDU_OBS_BUCKET_NAME: ${BAIDU_OBS_BUCKET_NAME:-} + BAIDU_OBS_SECRET_KEY: ${BAIDU_OBS_SECRET_KEY:-} + BAIDU_OBS_ACCESS_KEY: ${BAIDU_OBS_ACCESS_KEY:-} + BAIDU_OBS_ENDPOINT: ${BAIDU_OBS_ENDPOINT:-} VECTOR_STORE: ${VECTOR_STORE:-weaviate} WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080} WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} From 660fc3bb347b4b4b865ca2bdd449bb317cf84c24 Mon Sep 17 00:00:00 2001 From: Ziyu Huang Date: Sun, 20 Oct 2024 21:59:58 +0800 Subject: [PATCH 05/11] Resolve 9508 openai compatible rerank (#9511) --- .../openai_api_compatible.yaml | 14 ++ .../openai_api_compatible/rerank/__init__.py | 0 .../openai_api_compatible/rerank/rerank.py | 159 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 api/core/model_runtime/model_providers/openai_api_compatible/rerank/__init__.py create mode 100644 api/core/model_runtime/model_providers/openai_api_compatible/rerank/rerank.py diff --git a/api/core/model_runtime/model_providers/openai_api_compatible/openai_api_compatible.yaml b/api/core/model_runtime/model_providers/openai_api_compatible/openai_api_compatible.yaml index 88c76fe16e..69a081f35c 100644 --- a/api/core/model_runtime/model_providers/openai_api_compatible/openai_api_compatible.yaml +++ b/api/core/model_runtime/model_providers/openai_api_compatible/openai_api_compatible.yaml @@ -8,6 +8,7 @@ supported_model_types: - llm - text-embedding - speech2text + - rerank configurate_methods: - customizable-model model_credential_schema: @@ -83,6 +84,19 @@ model_credential_schema: placeholder: zh_Hans: 在此输入您的模型上下文长度 en_US: Enter your Model context size + - variable: context_size + label: + zh_Hans: 模型上下文长度 + en_US: Model context size + required: true + show_on: + - variable: __model_type + value: rerank + type: text-input + default: '4096' + placeholder: + zh_Hans: 在此输入您的模型上下文长度 + en_US: Enter your Model context size - variable: max_tokens_to_sample label: zh_Hans: 最大 token 上限 diff --git a/api/core/model_runtime/model_providers/openai_api_compatible/rerank/__init__.py b/api/core/model_runtime/model_providers/openai_api_compatible/rerank/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/core/model_runtime/model_providers/openai_api_compatible/rerank/rerank.py b/api/core/model_runtime/model_providers/openai_api_compatible/rerank/rerank.py new file mode 100644 index 0000000000..508da4bf20 --- /dev/null +++ b/api/core/model_runtime/model_providers/openai_api_compatible/rerank/rerank.py @@ -0,0 +1,159 @@ +from json import dumps +from typing import Optional + +import httpx +from requests import post +from yarl import URL + +from core.model_runtime.entities.common_entities import I18nObject +from core.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType +from core.model_runtime.entities.rerank_entities import RerankDocument, RerankResult +from core.model_runtime.errors.invoke import ( + InvokeAuthorizationError, + InvokeBadRequestError, + InvokeConnectionError, + InvokeError, + InvokeRateLimitError, + InvokeServerUnavailableError, +) +from core.model_runtime.errors.validate import CredentialsValidateFailedError +from core.model_runtime.model_providers.__base.rerank_model import RerankModel + + +class OAICompatRerankModel(RerankModel): + """ + rerank model API is compatible with Jina rerank model API. So copy the JinaRerankModel class code here. + we need enhance for llama.cpp , which return raw score, not normalize score 0~1. It seems Dify need it + """ + + def _invoke( + self, + model: str, + credentials: dict, + query: str, + docs: list[str], + score_threshold: Optional[float] = None, + top_n: Optional[int] = None, + user: Optional[str] = None, + ) -> RerankResult: + """ + Invoke rerank model + + :param model: model name + :param credentials: model credentials + :param query: search query + :param docs: docs for reranking + :param score_threshold: score threshold + :param top_n: top n documents to return + :param user: unique user id + :return: rerank result + """ + if len(docs) == 0: + return RerankResult(model=model, docs=[]) + + server_url = credentials["endpoint_url"] + model_name = model + + if not server_url: + raise CredentialsValidateFailedError("server_url is required") + if not model_name: + raise CredentialsValidateFailedError("model_name is required") + + url = server_url + headers = {"Authorization": f"Bearer {credentials.get('api_key')}", "Content-Type": "application/json"} + + # TODO: Do we need truncate docs to avoid llama.cpp return error? + + data = {"model": model_name, "query": query, "documents": docs, "top_n": top_n} + + try: + response = post(str(URL(url) / "rerank"), headers=headers, data=dumps(data), timeout=60) + response.raise_for_status() + results = response.json() + + rerank_documents = [] + scores = [result["relevance_score"] for result in results["results"]] + + # Min-Max Normalization: Normalize scores to 0 ~ 1.0 range + min_score = min(scores) + max_score = max(scores) + score_range = max_score - min_score if max_score != min_score else 1.0 # Avoid division by zero + + for result in results["results"]: + index = result["index"] + + # Retrieve document text (fallback if llama.cpp rerank doesn't return it) + text = result.get("document", {}).get("text", docs[index]) + + # Normalize the score + normalized_score = (result["relevance_score"] - min_score) / score_range + + # Create RerankDocument object with normalized score + rerank_document = RerankDocument( + index=index, + text=text, + score=normalized_score, + ) + + # Apply threshold (if defined) + if score_threshold is None or normalized_score >= score_threshold: + rerank_documents.append(rerank_document) + + # Sort rerank_documents by normalized score in descending order + rerank_documents.sort(key=lambda doc: doc.score, reverse=True) + + return RerankResult(model=model, docs=rerank_documents) + + except httpx.HTTPStatusError as e: + raise InvokeServerUnavailableError(str(e)) + + def validate_credentials(self, model: str, credentials: dict) -> None: + """ + Validate model credentials + + :param model: model name + :param credentials: model credentials + :return: + """ + try: + self._invoke( + model=model, + credentials=credentials, + query="What is the capital of the United States?", + docs=[ + "Carson City is the capital city of the American state of Nevada. At the 2010 United States " + "Census, Carson City had a population of 55,274.", + "The Commonwealth of the Northern Mariana Islands is a group of islands in the Pacific Ocean that " + "are a political division controlled by the United States. Its capital is Saipan.", + ], + score_threshold=0.8, + ) + except Exception as ex: + raise CredentialsValidateFailedError(str(ex)) + + @property + def _invoke_error_mapping(self) -> dict[type[InvokeError], list[type[Exception]]]: + """ + Map model invoke error to unified error + """ + return { + InvokeConnectionError: [httpx.ConnectError], + InvokeServerUnavailableError: [httpx.RemoteProtocolError], + InvokeRateLimitError: [], + InvokeAuthorizationError: [httpx.HTTPStatusError], + InvokeBadRequestError: [httpx.RequestError], + } + + def get_customizable_model_schema(self, model: str, credentials: dict) -> AIModelEntity: + """ + generate custom model entities from credentials + """ + entity = AIModelEntity( + model=model, + label=I18nObject(en_US=model), + model_type=ModelType.RERANK, + fetch_from=FetchFrom.CUSTOMIZABLE_MODEL, + model_properties={}, + ) + + return entity From 95ce10f23bb848a13c51706945b200e9a6761b7f Mon Sep 17 00:00:00 2001 From: ice yao Date: Sun, 20 Oct 2024 22:06:18 +0800 Subject: [PATCH 06/11] feat: Add custom username and avatar define in discord tool (#9514) --- .../builtin/discord/tools/discord_webhook.py | 6 ++--- .../discord/tools/discord_webhook.yaml | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/api/core/tools/provider/builtin/discord/tools/discord_webhook.py b/api/core/tools/provider/builtin/discord/tools/discord_webhook.py index 7fdf791aba..c1834a1a26 100644 --- a/api/core/tools/provider/builtin/discord/tools/discord_webhook.py +++ b/api/core/tools/provider/builtin/discord/tools/discord_webhook.py @@ -21,7 +21,6 @@ class DiscordWebhookTool(BuiltinTool): return self.create_text_message("Invalid parameter content") webhook_url = tool_parameters.get("webhook_url", "") - if not webhook_url.startswith("https://discord.com/api/webhooks/"): return self.create_text_message( f"Invalid parameter webhook_url ${webhook_url}, \ @@ -31,13 +30,14 @@ class DiscordWebhookTool(BuiltinTool): headers = { "Content-Type": "application/json", } - params = {} payload = { + "username": tool_parameters.get("username") or user_id, "content": content, + "avatar_url": tool_parameters.get("avatar_url") or None, } try: - res = httpx.post(webhook_url, headers=headers, params=params, json=payload) + res = httpx.post(webhook_url, headers=headers, json=payload) if res.is_success: return self.create_text_message("Text message was sent successfully") else: diff --git a/api/core/tools/provider/builtin/discord/tools/discord_webhook.yaml b/api/core/tools/provider/builtin/discord/tools/discord_webhook.yaml index bb3fa43f24..6847b973ca 100644 --- a/api/core/tools/provider/builtin/discord/tools/discord_webhook.yaml +++ b/api/core/tools/provider/builtin/discord/tools/discord_webhook.yaml @@ -38,3 +38,28 @@ parameters: pt_BR: Content to sent to the channel or person. llm_description: Content of the message form: llm + - name: username + type: string + required: false + label: + en_US: Discord Webhook Username + zh_Hans: Discord Webhook用户名 + pt_BR: Discord Webhook Username + human_description: + en_US: Discord Webhook Username + zh_Hans: Discord Webhook用户名 + pt_BR: Discord Webhook Username + llm_description: Discord Webhook Username + form: llm + - name: avatar_url + type: string + required: false + label: + en_US: Discord Webhook Avatar + zh_Hans: Discord Webhook头像 + pt_BR: Discord Webhook Avatar + human_description: + en_US: Discord Webhook Avatar URL + zh_Hans: Discord Webhook头像地址 + pt_BR: Discord Webhook Avatar URL + form: form From 444dc019313de08657dec298d5fc94b94c136a74 Mon Sep 17 00:00:00 2001 From: kurokobo Date: Sun, 20 Oct 2024 23:53:32 +0900 Subject: [PATCH 07/11] fix: ignore all files except for .gitkeep under docker/nginx/ssl by gitignore (#9518) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 29374d13dc..27cf8a4ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,8 @@ docker/volumes/pgvector/data/* docker/volumes/pgvecto_rs/data/* docker/nginx/conf.d/default.conf +docker/nginx/ssl/* +!docker/nginx/ssl/.gitkeep docker/middleware.env sdks/python-client/build From 42fe208edad6efcb85dff7ac00cd7d664fd61158 Mon Sep 17 00:00:00 2001 From: chzphoenix Date: Mon, 21 Oct 2024 09:03:25 +0800 Subject: [PATCH 08/11] refactor wenxin rerank (#9486) Co-authored-by: cuihz --- .../model_providers/wenxin/rerank/rerank.py | 41 ++++--------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/api/core/model_runtime/model_providers/wenxin/rerank/rerank.py b/api/core/model_runtime/model_providers/wenxin/rerank/rerank.py index b22aead22b..9e6a7dd99e 100644 --- a/api/core/model_runtime/model_providers/wenxin/rerank/rerank.py +++ b/api/core/model_runtime/model_providers/wenxin/rerank/rerank.py @@ -2,20 +2,15 @@ from typing import Optional import httpx -from core.model_runtime.entities.common_entities import I18nObject -from core.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelPropertyKey, ModelType from core.model_runtime.entities.rerank_entities import RerankDocument, RerankResult -from core.model_runtime.errors.invoke import ( - InvokeAuthorizationError, - InvokeBadRequestError, - InvokeConnectionError, - InvokeError, - InvokeRateLimitError, - InvokeServerUnavailableError, -) +from core.model_runtime.errors.invoke import InvokeError from core.model_runtime.errors.validate import CredentialsValidateFailedError from core.model_runtime.model_providers.__base.rerank_model import RerankModel from core.model_runtime.model_providers.wenxin._common import _CommonWenxin +from core.model_runtime.model_providers.wenxin.wenxin_errors import ( + InternalServerError, + invoke_error_mapping, +) class WenxinRerank(_CommonWenxin): @@ -32,7 +27,7 @@ class WenxinRerank(_CommonWenxin): response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: - raise InvokeServerUnavailableError(str(e)) + raise InternalServerError(str(e)) class WenxinRerankModel(RerankModel): @@ -93,7 +88,7 @@ class WenxinRerankModel(RerankModel): return RerankResult(model=model, docs=rerank_documents) except httpx.HTTPStatusError as e: - raise InvokeServerUnavailableError(str(e)) + raise InternalServerError(str(e)) def validate_credentials(self, model: str, credentials: dict) -> None: """ @@ -124,24 +119,4 @@ class WenxinRerankModel(RerankModel): """ Map model invoke error to unified error """ - return { - InvokeConnectionError: [httpx.ConnectError], - InvokeServerUnavailableError: [httpx.RemoteProtocolError], - InvokeRateLimitError: [], - InvokeAuthorizationError: [httpx.HTTPStatusError], - InvokeBadRequestError: [httpx.RequestError], - } - - def get_customizable_model_schema(self, model: str, credentials: dict) -> AIModelEntity: - """ - generate custom model entities from credentials - """ - entity = AIModelEntity( - model=model, - label=I18nObject(en_US=model), - model_type=ModelType.RERANK, - fetch_from=FetchFrom.CUSTOMIZABLE_MODEL, - model_properties={ModelPropertyKey.CONTEXT_SIZE: int(credentials.get("context_size"))}, - ) - - return entity + return invoke_error_mapping() From 853b0e84ccb21390ad0b88540480443afdd2c396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 21 Oct 2024 09:05:42 +0800 Subject: [PATCH 09/11] fix: draft run workflow node with image will raise error (#9406) --- api/core/workflow/workflow_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index 74a598ada5..d3576197d1 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -289,7 +289,7 @@ class WorkflowEntry: new_value.append(file) if new_value: - value = new_value + input_value = new_value # append variable and value to variable pool variable_pool.add([variable_node_id] + variable_key_list, input_value) From 3898fe3311a530347d0520f039e355338e3e06fa Mon Sep 17 00:00:00 2001 From: NFish Date: Mon, 21 Oct 2024 09:23:20 +0800 Subject: [PATCH 10/11] New Auth Methods (#8119) --- web/app/account/account-page/index.tsx | 79 +++- web/app/account/avatar.tsx | 2 +- web/app/activate/activateForm.tsx | 202 +--------- .../base/icons/assets/public/common/lock.svg | 5 + .../account-setting/account-page/index.tsx | 7 +- web/app/components/signin/countdown.tsx | 41 ++ web/app/components/swr-initor.tsx | 70 +++- .../forgot-password/ChangePasswordForm.tsx | 41 +- .../forgot-password/ForgotPasswordForm.tsx | 6 +- web/app/install/installForm.tsx | 3 +- web/app/reset-password/check-code/page.tsx | 92 +++++ web/app/reset-password/layout.tsx | 39 ++ web/app/reset-password/page.tsx | 101 +++++ web/app/reset-password/set-password/page.tsx | 193 +++++++++ web/app/signin/check-code/page.tsx | 96 +++++ .../signin/components/mail-and-code-auth.tsx | 71 ++++ .../components/mail-and-password-auth.tsx | 167 ++++++++ web/app/signin/components/social-auth.tsx | 62 +++ web/app/signin/components/sso-auth.tsx | 73 ++++ web/app/signin/forms.tsx | 34 -- web/app/signin/invite-settings/page.tsx | 154 +++++++ web/app/signin/layout.tsx | 54 +++ web/app/signin/normalForm.tsx | 377 ++++++------------ web/app/signin/oneMoreStep.tsx | 34 +- web/app/signin/page.tsx | 95 +---- web/app/signin/userSSOForm.tsx | 107 ----- web/i18n/en-US/login.ts | 42 +- web/i18n/zh-Hans/login.ts | 43 +- web/i18n/zh-Hant/login.ts | 4 +- web/service/common.ts | 26 +- web/service/sso.ts | 15 +- web/types/feature.ts | 20 +- 32 files changed, 1568 insertions(+), 787 deletions(-) create mode 100644 web/app/components/base/icons/assets/public/common/lock.svg create mode 100644 web/app/components/signin/countdown.tsx create mode 100644 web/app/reset-password/check-code/page.tsx create mode 100644 web/app/reset-password/layout.tsx create mode 100644 web/app/reset-password/page.tsx create mode 100644 web/app/reset-password/set-password/page.tsx create mode 100644 web/app/signin/check-code/page.tsx create mode 100644 web/app/signin/components/mail-and-code-auth.tsx create mode 100644 web/app/signin/components/mail-and-password-auth.tsx create mode 100644 web/app/signin/components/social-auth.tsx create mode 100644 web/app/signin/components/sso-auth.tsx delete mode 100644 web/app/signin/forms.tsx create mode 100644 web/app/signin/invite-settings/page.tsx create mode 100644 web/app/signin/layout.tsx delete mode 100644 web/app/signin/userSSOForm.tsx diff --git a/web/app/account/account-page/index.tsx b/web/app/account/account-page/index.tsx index 53f7692e6c..b42b481eba 100644 --- a/web/app/account/account-page/index.tsx +++ b/web/app/account/account-page/index.tsx @@ -15,6 +15,7 @@ import { ToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import Avatar from '@/app/components/base/avatar' import { IS_CE_EDITION } from '@/config' +import Input from '@/app/components/base/input' const titleClassName = ` text-sm font-medium text-gray-900 @@ -31,6 +32,7 @@ const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ export default function AccountPage() { const { t } = useTranslation() + const { systemFeatures } = useAppContext() const { mutateUserProfile, userProfile, apps } = useAppContext() const { notify } = useContext(ToastContext) const [editNameModalVisible, setEditNameModalVisible] = useState(false) @@ -41,6 +43,9 @@ export default function AccountPage() { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) + const [showCurrentPassword, setShowCurrentPassword] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) const handleEditName = () => { setEditNameModalVisible(true) @@ -158,8 +163,8 @@ export default function AccountPage() { { - IS_CE_EDITION && ( -
+ systemFeatures.enable_email_password_login && ( +
{t('common.account.password')}
{t('common.account.passwordTip')}
@@ -191,8 +196,7 @@ export default function AccountPage() { >
{t('common.account.editName')}
{t('common.account.name')}
- setEditName(e.target.value)} /> @@ -223,30 +227,61 @@ export default function AccountPage() { {userProfile.is_password_set && ( <>
{t('common.account.currentPassword')}
- setCurrentPassword(e.target.value)} - /> +
+ setCurrentPassword(e.target.value)} + /> + +
+ +
+
)}
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
- setPassword(e.target.value)} - /> +
+ setPassword(e.target.value)} + /> +
+ +
+
{t('common.account.confirmPassword')}
- setConfirmPassword(e.target.value)} - /> +
+ setConfirmPassword(e.target.value)} + /> +
+ +
+
)} - {checkRes && checkRes.is_valid && !showSuccess && ( -
-
-
-
-

- {`${t('login.join')} ${checkRes.workspace_name}`} -

-

- {`${t('login.joinTipStart')} ${checkRes.workspace_name} ${t('login.joinTipEnd')}`} -

-
- -
-
- {/* username */} -
- -
- setName(e.target.value)} - placeholder={t('login.namePlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} - tabIndex={1} - /> -
-
- {/* password */} -
- -
- setPassword(e.target.value)} - placeholder={t('login.passwordPlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} - tabIndex={2} - /> -
-
{t('login.error.passwordInvalid')}
-
- {/* language */} -
- -
- item.supported)} - onSelect={(item) => { - setLanguage(item.value as string) - }} - /> -
-
- {/* timezone */} -
- -
- { - setTimezone(item.value as string) - }} - /> -
-
-
- -
-
- {t('login.license.tip')} -   - {t('login.license.link')} -
-
-
-
- )} - {checkRes && checkRes.is_valid && showSuccess && ( -
-
-
- -
-

- {`${t('login.activatedTipStart')} ${checkRes.workspace_name} ${t('login.activatedTipEnd')}`} -

-
- -
- )}
) } diff --git a/web/app/components/base/icons/assets/public/common/lock.svg b/web/app/components/base/icons/assets/public/common/lock.svg new file mode 100644 index 0000000000..a6987846f7 --- /dev/null +++ b/web/app/components/base/icons/assets/public/common/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/header/account-setting/account-page/index.tsx b/web/app/components/header/account-setting/account-page/index.tsx index eecd275b35..7b400dd50d 100644 --- a/web/app/components/header/account-setting/account-page/index.tsx +++ b/web/app/components/header/account-setting/account-page/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext, useContextSelector } from 'use-context-selector' import Collapse from '../collapse' import type { IItem } from '../collapse' import s from './index.module.css' @@ -11,7 +11,7 @@ import Modal from '@/app/components/base/modal' import Confirm from '@/app/components/base/confirm' import Button from '@/app/components/base/button' import { updateUserProfile } from '@/service/common' -import { useAppContext } from '@/context/app-context' +import AppContext, { useAppContext } from '@/context/app-context' import { ToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import Avatar from '@/app/components/base/avatar' @@ -42,6 +42,7 @@ export default function AccountPage() { const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) + const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) const handleEditName = () => { setEditNameModalVisible(true) @@ -144,7 +145,7 @@ export default function AccountPage() {
{t('common.account.email')}
{userProfile.email}
- {IS_CE_EDITION && ( + {systemFeatures.enable_email_password_login && (
{t('common.account.password')}
{t('common.account.passwordTip')}
diff --git a/web/app/components/signin/countdown.tsx b/web/app/components/signin/countdown.tsx new file mode 100644 index 0000000000..6282480d10 --- /dev/null +++ b/web/app/components/signin/countdown.tsx @@ -0,0 +1,41 @@ +'use client' +import { useCountDown } from 'ahooks' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export const COUNT_DOWN_TIME_MS = 59000 +export const COUNT_DOWN_KEY = 'leftTime' + +type CountdownProps = { + onResend?: () => void +} + +export default function Countdown({ onResend }: CountdownProps) { + const { t } = useTranslation() + const [leftTime, setLeftTime] = useState(Number(localStorage.getItem(COUNT_DOWN_KEY) || COUNT_DOWN_TIME_MS)) + const [time] = useCountDown({ + leftTime, + onEnd: () => { + setLeftTime(0) + localStorage.removeItem(COUNT_DOWN_KEY) + }, + }) + + const resend = async function () { + setLeftTime(COUNT_DOWN_TIME_MS) + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + onResend?.() + } + + useEffect(() => { + localStorage.setItem(COUNT_DOWN_KEY, `${time}`) + }, [time]) + + return

+ {t('login.checkCode.didNotReceiveCode')} + {time > 0 && {Math.round(time / 1000)}s} + { + time <= 0 && {t('login.checkCode.resend')} + } +

+} diff --git a/web/app/components/swr-initor.tsx b/web/app/components/swr-initor.tsx index ce126512fa..89141359d6 100644 --- a/web/app/components/swr-initor.tsx +++ b/web/app/components/swr-initor.tsx @@ -1,10 +1,11 @@ 'use client' import { SWRConfig } from 'swr' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import type { ReactNode } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import useRefreshToken from '@/hooks/use-refresh-token' +import { fetchSetupStatus } from '@/service/common' type SwrInitorProps = { children: ReactNode @@ -21,27 +22,60 @@ const SwrInitor = ({ const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token') const [init, setInit] = useState(false) - useEffect(() => { - if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) { - router.replace('/signin') - return + const isSetupFinished = useCallback(async () => { + try { + if (localStorage.getItem('setup_status') === 'finished') + return true + const setUpStatus = await fetchSetupStatus() + if (setUpStatus.step !== 'finished') { + localStorage.removeItem('setup_status') + return false + } + localStorage.setItem('setup_status', 'finished') + return true } - if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) - getNewAccessToken() - - if (consoleToken && refreshToken) { - localStorage.setItem('console_token', consoleToken) - localStorage.setItem('refresh_token', refreshToken) - getNewAccessToken().then(() => { - router.replace('/apps', { forceOptimisticNavigation: false } as any) - }).catch(() => { - router.replace('/signin') - }) + catch (error) { + console.error(error) + return false } - - setInit(true) }, []) + const setRefreshToken = useCallback(async () => { + try { + if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) + return Promise.reject(new Error('No token found')) + + if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage) + await getNewAccessToken() + + if (consoleToken && refreshToken) { + localStorage.setItem('console_token', consoleToken) + localStorage.setItem('refresh_token', refreshToken) + await getNewAccessToken() + } + } + catch (error) { + return Promise.reject(error) + } + }, [consoleToken, refreshToken, consoleTokenFromLocalStorage, refreshTokenFromLocalStorage, getNewAccessToken]) + + useEffect(() => { + (async () => { + try { + const isFinished = await isSetupFinished() + if (!isFinished) { + router.replace('/install') + return + } + await setRefreshToken() + setInit(true) + } + catch (error) { + router.replace('/signin') + } + })() + }, [isSetupFinished, setRefreshToken, router]) + return init ? ( { -
- setPassword(e.target.value)} - placeholder={t('login.passwordPlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} - /> -
-
{t('login.error.passwordInvalid')}
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + className='mt-1' + /> +
{t('login.error.passwordInvalid')}
{/* Confirm Password */}
-
- setConfirmPassword(e.target.value)} - placeholder={t('login.confirmPasswordPlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} - /> -
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + className='mt-1' + />
diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 6fd69a3638..df744924da 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' import Loading from '../components/base/loading' +import Input from '../components/base/input' import Button from '@/app/components/base/button' import { @@ -78,7 +79,7 @@ const ForgotPasswordForm = () => { return ( loading - ? + ? : <>

@@ -98,10 +99,9 @@ const ForgotPasswordForm = () => { {t('login.email')}
- {errors.email && {t(`${errors.email?.message}`)}}
diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 0db88c8e25..abf377e389 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -65,6 +65,7 @@ const InstallForm = () => { useEffect(() => { fetchSetupStatus().then((res: SetupStatusResponse) => { if (res.step === 'finished') { + localStorage.setItem('setup_status', 'finished') window.location.href = '/signin' } else { @@ -153,7 +154,7 @@ const InstallForm = () => {

-
+
{t('login.license.tip')}   { + try { + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + setIsLoading(true) + const ret = await verifyResetPasswordCode({ email, code, token }) + ret.is_valid && router.push(`/reset-password/set-password?${searchParams.toString()}`) + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const res = await sendResetPasswordCode(email, locale) + if (res.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + router.replace(`/reset-password/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/reset-password/layout.tsx b/web/app/reset-password/layout.tsx new file mode 100644 index 0000000000..16d8642ed2 --- /dev/null +++ b/web/app/reset-password/layout.tsx @@ -0,0 +1,39 @@ +import Header from '../signin/_header' +import style from '../signin/page.module.css' + +import cn from '@/utils/classnames' + +export default async function SignInLayout({ children }: any) { + return <> +
+
+
+
+
+ {children} +
+
+
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
+
+
+ +} diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx new file mode 100644 index 0000000000..65f1db3fb5 --- /dev/null +++ b/web/app/reset-password/page.tsx @@ -0,0 +1,101 @@ +'use client' +import Link from 'next/link' +import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' +import { emailRegex } from '@/config' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { sendResetPasswordCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const searchParams = useSearchParams() + const router = useRouter() + const [email, setEmail] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const res = await sendResetPasswordCode(email, locale) + if (res.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(res.data)) + params.set('email', encodeURIComponent(email)) + router.push(`/reset-password/check-code?${params.toString()}`) + } + else if (res.code === 'account_not_found') { + Toast.notify({ + type: 'error', + message: t('login.error.registrationNotAllowed'), + }) + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return
+
+ +
+
+

{t('login.resetPassword')}

+

+ {t('login.resetPasswordDesc')} +

+
+ +
{ }}> + +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+
+
+
+ +
+ +
+ {t('login.backToLogin')} + +
+} diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx new file mode 100644 index 0000000000..7948c59a9a --- /dev/null +++ b/web/app/reset-password/set-password/page.tsx @@ -0,0 +1,193 @@ +'use client' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import cn from 'classnames' +import { RiCheckboxCircleFill } from '@remixicon/react' +import { useCountDown } from 'ahooks' +import Button from '@/app/components/base/button' +import { changePasswordWithToken } from '@/service/common' +import Toast from '@/app/components/base/toast' +import Input from '@/app/components/base/input' + +const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +const ChangePasswordForm = () => { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('token') || '') + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showSuccess, setShowSuccess] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + const showErrorMessage = useCallback((message: string) => { + Toast.notify({ + type: 'error', + message, + }) + }, []) + + const getSignInUrl = () => { + if (searchParams.has('invite_token')) { + const params = new URLSearchParams() + params.set('token', searchParams.get('invite_token') as string) + return `/activate?${params.toString()}` + } + return '/signin' + } + + const AUTO_REDIRECT_TIME = 5000 + const [leftTime, setLeftTime] = useState(undefined) + const [countdown] = useCountDown({ + leftTime, + onEnd: () => { + router.replace(getSignInUrl()) + }, + }) + + const valid = useCallback(() => { + if (!password.trim()) { + showErrorMessage(t('login.error.passwordEmpty')) + return false + } + if (!validPassword.test(password)) { + showErrorMessage(t('login.error.passwordInvalid')) + return false + } + if (password !== confirmPassword) { + showErrorMessage(t('common.account.notEqual')) + return false + } + return true + }, [password, confirmPassword, showErrorMessage, t]) + + const handleChangePassword = useCallback(async () => { + if (!valid()) + return + try { + await changePasswordWithToken({ + url: '/forgot-password/resets', + body: { + token, + new_password: password, + password_confirm: confirmPassword, + }, + }) + setShowSuccess(true) + setLeftTime(AUTO_REDIRECT_TIME) + } + catch (error) { + console.error(error) + } + }, [password, token, valid, confirmPassword]) + + return ( +
+ {!showSuccess && ( +
+
+

+ {t('login.changePassword')} +

+

+ {t('login.changePasswordTip')} +

+
+ +
+
+ {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder={t('login.passwordPlaceholder') || ''} + /> + +
+ +
+
+
{t('login.error.passwordInvalid')}
+
+ {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + placeholder={t('login.confirmPasswordPlaceholder') || ''} + /> +
+ +
+
+
+
+ +
+
+
+
+ )} + {showSuccess && ( +
+
+
+ +
+

+ {t('login.passwordChangedTip')} +

+
+
+ +
+
+ )} +
+ ) +} + +export default ChangePasswordForm diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx new file mode 100644 index 0000000000..4767308f72 --- /dev/null +++ b/web/app/signin/check-code/page.tsx @@ -0,0 +1,96 @@ +'use client' +import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Countdown from '@/app/components/signin/countdown' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import Toast from '@/app/components/base/toast' +import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' +import I18NContext from '@/context/i18n' + +export default function CheckCode() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const email = decodeURIComponent(searchParams.get('email') as string) + const token = decodeURIComponent(searchParams.get('token') as string) + const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') + const [code, setVerifyCode] = useState('') + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const verify = async () => { + try { + if (!code.trim()) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.emptyCode'), + }) + return + } + if (!/\d{6}/.test(code)) { + Toast.notify({ + type: 'error', + message: t('login.checkCode.invalidCode'), + }) + return + } + setIsLoading(true) + const ret = await emailLoginWithCode({ email, code, token }) + if (ret.result === 'success') { + localStorage.setItem('console_token', ret.data.access_token) + localStorage.setItem('refresh_token', ret.data.refresh_token) + router.replace(invite_token ? `/signin/invite-settings?${searchParams.toString()}` : '/apps') + } + } + catch (error) { console.error(error) } + finally { + setIsLoading(false) + } + } + + const resendCode = async () => { + try { + const ret = await sendEMailLoginCode(email, locale) + if (ret.result === 'success') { + const params = new URLSearchParams(searchParams) + params.set('token', encodeURIComponent(ret.data)) + router.replace(`/signin/check-code?${params.toString()}`) + } + } + catch (error) { console.error(error) } + } + + return
+
+ +
+
+

{t('login.checkCode.checkYourEmail')}

+

+ +
+ {t('login.checkCode.validTime')} +

+
+ +
+ + setVerifyCode(e.target.value)} max-length={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') as string} /> + + + +
+
+
+
router.back()} className='flex items-center justify-center h-9 text-text-tertiary cursor-pointer'> +
+ +
+ {t('login.back')} +
+
+} diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx new file mode 100644 index 0000000000..7225b094d4 --- /dev/null +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Input from '@/app/components/base/input' +import Button from '@/app/components/base/button' +import { emailRegex } from '@/config' +import Toast from '@/app/components/base/toast' +import { sendEMailLoginCode } from '@/service/common' +import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' +import I18NContext from '@/context/i18n' + +type MailAndCodeAuthProps = { + isInvite: boolean +} + +export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [loading, setIsLoading] = useState(false) + const { locale } = useContext(I18NContext) + + const handleGetEMailVerificationCode = async () => { + try { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + setIsLoading(true) + const ret = await sendEMailLoginCode(email, locale) + if (ret.result === 'success') { + localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`) + const params = new URLSearchParams(searchParams) + params.set('email', encodeURIComponent(email)) + params.set('token', encodeURIComponent(ret.data)) + router.push(`/signin/check-code?${params.toString()}`) + } + } + catch (error) { + console.error(error) + } + finally { + setIsLoading(false) + } + } + + return (
{ }}> + +
+ +
+ setEmail(e.target.value)} /> +
+
+ +
+
+
+ ) +} diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx new file mode 100644 index 0000000000..210c877bb7 --- /dev/null +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -0,0 +1,167 @@ +import Link from 'next/link' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from 'next/navigation' +import { useContext } from 'use-context-selector' +import Button from '@/app/components/base/button' +import Toast from '@/app/components/base/toast' +import { emailRegex } from '@/config' +import { login } from '@/service/common' +import Input from '@/app/components/base/input' +import I18NContext from '@/context/i18n' + +type MailAndPasswordAuthProps = { + isInvite: boolean + allowRegistration: boolean +} + +const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/ + +export default function MailAndPasswordAuth({ isInvite, allowRegistration }: MailAndPasswordAuthProps) { + const { t } = useTranslation() + const { locale } = useContext(I18NContext) + const router = useRouter() + const searchParams = useSearchParams() + const [showPassword, setShowPassword] = useState(false) + const emailFromLink = decodeURIComponent(searchParams.get('email') || '') + const [email, setEmail] = useState(emailFromLink) + const [password, setPassword] = useState('') + + const [isLoading, setIsLoading] = useState(false) + const handleEmailPasswordLogin = async () => { + if (!email) { + Toast.notify({ type: 'error', message: t('login.error.emailEmpty') }) + return + } + if (!emailRegex.test(email)) { + Toast.notify({ + type: 'error', + message: t('login.error.emailInValid'), + }) + return + } + if (!password?.trim()) { + Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') }) + return + } + if (!passwordRegex.test(password)) { + Toast.notify({ + type: 'error', + message: t('login.error.passwordInvalid'), + }) + return + } + try { + setIsLoading(true) + const loginData: Record = { + email, + password, + language: locale, + remember_me: true, + } + if (isInvite) + loginData.invite_token = decodeURIComponent(searchParams.get('invite_token') as string) + const res = await login({ + url: '/login', + body: loginData, + }) + if (res.result === 'success') { + if (isInvite) { + router.replace(`/signin/invite-settings?${searchParams.toString()}`) + } + else { + localStorage.setItem('console_token', res.data.access_token) + localStorage.setItem('refresh_token', res.data.refresh_token) + router.replace('/apps') + } + } + else if (res.code === 'account_not_found') { + if (allowRegistration) { + const params = new URLSearchParams() + params.append('email', encodeURIComponent(email)) + params.append('token', encodeURIComponent(res.data)) + router.replace(`/reset-password/check-code?${params.toString()}`) + } + else { + Toast.notify({ + type: 'error', + message: t('login.error.registrationNotAllowed'), + }) + } + } + else { + Toast.notify({ + type: 'error', + message: res.data, + }) + } + } + + finally { + setIsLoading(false) + } + } + + return
{ }}> +
+ +
+ setEmail(e.target.value)} + disabled={isInvite} + id="email" + type="email" + autoComplete="email" + placeholder={t('login.emailPlaceholder') || ''} + tabIndex={1} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') + handleEmailPasswordLogin() + }} + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + placeholder={t('login.passwordPlaceholder') || ''} + tabIndex={2} + /> +
+ +
+
+
+ +
+ +
+
+} diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx new file mode 100644 index 0000000000..39d7ceaa40 --- /dev/null +++ b/web/app/signin/components/social-auth.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'next/navigation' +import style from '../page.module.css' +import Button from '@/app/components/base/button' +import { apiPrefix } from '@/config' +import classNames from '@/utils/classnames' +import { getPurifyHref } from '@/utils' + +type SocialAuthProps = { + disabled?: boolean +} + +export default function SocialAuth(props: SocialAuthProps) { + const { t } = useTranslation() + const searchParams = useSearchParams() + + const getOAuthLink = (href: string) => { + const url = getPurifyHref(`${apiPrefix}${href}`) + if (searchParams.has('invite_token')) + return `${url}?${searchParams.toString()}` + + return url + } + return <> + + + +} diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx new file mode 100644 index 0000000000..fb303b93e2 --- /dev/null +++ b/web/app/signin/components/sso-auth.tsx @@ -0,0 +1,73 @@ +'use client' +import { useRouter, useSearchParams } from 'next/navigation' +import type { FC } from 'react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Toast from '@/app/components/base/toast' +import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso' +import Button from '@/app/components/base/button' +import { SSOProtocol } from '@/types/feature' + +type SSOAuthProps = { + protocol: SSOProtocol | '' +} + +const SSOAuth: FC = ({ + protocol, +}) => { + const router = useRouter() + const { t } = useTranslation() + const searchParams = useSearchParams() + const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') + + const [isLoading, setIsLoading] = useState(false) + + const handleSSOLogin = () => { + setIsLoading(true) + if (protocol === SSOProtocol.SAML) { + getUserSAMLSSOUrl(invite_token).then((res) => { + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OIDC) { + getUserOIDCSSOUrl(invite_token).then((res) => { + document.cookie = `user-oidc-state=${res.state}` + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else if (protocol === SSOProtocol.OAuth2) { + getUserOAuth2SSOUrl(invite_token).then((res) => { + document.cookie = `user-oauth2-state=${res.state}` + router.push(res.url) + }).finally(() => { + setIsLoading(false) + }) + } + else { + Toast.notify({ + type: 'error', + message: 'invalid SSO protocol', + }) + setIsLoading(false) + } + } + + return ( + + ) +} + +export default SSOAuth diff --git a/web/app/signin/forms.tsx b/web/app/signin/forms.tsx deleted file mode 100644 index 70a34c26fa..0000000000 --- a/web/app/signin/forms.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client' -import React from 'react' -import { useSearchParams } from 'next/navigation' - -import NormalForm from './normalForm' -import OneMoreStep from './oneMoreStep' -import cn from '@/utils/classnames' - -const Forms = () => { - const searchParams = useSearchParams() - const step = searchParams.get('step') - - const getForm = () => { - switch (step) { - case 'next': - return - default: - return - } - } - return
-
- {getForm()} -
-
-} - -export default Forms diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx new file mode 100644 index 0000000000..2138399ec3 --- /dev/null +++ b/web/app/signin/invite-settings/page.tsx @@ -0,0 +1,154 @@ +'use client' +import { useTranslation } from 'react-i18next' +import { useCallback, useState } from 'react' +import Link from 'next/link' +import { useContext } from 'use-context-selector' +import { useRouter, useSearchParams } from 'next/navigation' +import useSWR from 'swr' +import { RiAccountCircleLine } from '@remixicon/react' +import Input from '@/app/components/base/input' +import { SimpleSelect } from '@/app/components/base/select' +import Button from '@/app/components/base/button' +import { timezones } from '@/utils/timezone' +import { LanguagesSupported, languages } from '@/i18n/language' +import I18n from '@/context/i18n' +import { activateMember, invitationCheck } from '@/service/common' +import Loading from '@/app/components/base/loading' +import Toast from '@/app/components/base/toast' + +export default function InviteSettingsPage() { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const token = decodeURIComponent(searchParams.get('invite_token') as string) + const { locale, setLocaleOnClient } = useContext(I18n) + const [name, setName] = useState('') + const [language, setLanguage] = useState(LanguagesSupported[0]) + const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles') + + const checkParams = { + url: '/activate/check', + params: { + token, + }, + } + const { data: checkRes, mutate: recheck } = useSWR(checkParams, invitationCheck, { + revalidateOnFocus: false, + }) + + const handleActivate = useCallback(async () => { + try { + if (!name) { + Toast.notify({ type: 'error', message: t('login.enterYourName') }) + return + } + const res = await activateMember({ + url: '/activate', + body: { + token, + name, + interface_language: language, + timezone, + }, + }) + if (res.result === 'success') { + localStorage.setItem('console_token', res.data.access_token) + localStorage.setItem('refresh_token', res.data.refresh_token) + setLocaleOnClient(language, false) + router.replace('/apps') + } + } + catch { + recheck() + } + }, [language, name, recheck, setLocaleOnClient, timezone, token, router, t]) + + if (!checkRes) + return + if (!checkRes.is_valid) { + return
+
+
🤷‍♂️
+

{t('login.invalid')}

+
+ +
+ } + + return
+
+ +
+
+

{t('login.setYourAccount')}

+
+
+ +
+ +
+ setName(e.target.value)} + placeholder={t('login.namePlaceholder') || ''} + /> +
+
+
+ +
+ item.supported)} + onSelect={(item) => { + setLanguage(item.value as string) + }} + /> +
+
+ {/* timezone */} +
+ +
+ { + setTimezone(item.value as string) + }} + /> +
+
+
+ +
+
+
+ {t('login.license.tip')} +   + {t('login.license.link')} +
+
+} diff --git a/web/app/signin/layout.tsx b/web/app/signin/layout.tsx new file mode 100644 index 0000000000..342876bc53 --- /dev/null +++ b/web/app/signin/layout.tsx @@ -0,0 +1,54 @@ +import Script from 'next/script' +import Header from './_header' +import style from './page.module.css' + +import cn from '@/utils/classnames' +import { IS_CE_EDITION } from '@/config' + +export default async function SignInLayout({ children }: any) { + return <> + {!IS_CE_EDITION && ( + <> + + + + )} + +
+
+
+
+
+ {children} +
+
+
+ © {new Date().getFullYear()} LangGenius, Inc. All rights reserved. +
+
+
+ +} diff --git a/web/app/signin/normalForm.tsx b/web/app/signin/normalForm.tsx index 113ed64b57..c0f2d89b37 100644 --- a/web/app/signin/normalForm.tsx +++ b/web/app/signin/normalForm.tsx @@ -1,299 +1,170 @@ -'use client' -import React, { useEffect, useReducer, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useRouter } from 'next/navigation' -import useSWR from 'swr' import Link from 'next/link' -import Toast from '../components/base/toast' -import style from './page.module.css' -import classNames from '@/utils/classnames' -import { IS_CE_EDITION, SUPPORT_MAIL_LOGIN, apiPrefix, emailRegex } from '@/config' -import Button from '@/app/components/base/button' -import { login, oauth } from '@/service/common' -import { getPurifyHref } from '@/utils' +import { useRouter, useSearchParams } from 'next/navigation' +import { RiDoorLockLine } from '@remixicon/react' +import Loading from '../components/base/loading' +import MailAndCodeAuth from './components/mail-and-code-auth' +import MailAndPasswordAuth from './components/mail-and-password-auth' +import SocialAuth from './components/social-auth' +import SSOAuth from './components/sso-auth' +import cn from '@/utils/classnames' +import { getSystemFeatures, invitationCheck } from '@/service/common' +import { defaultSystemFeatures } from '@/types/feature' +import Toast from '@/app/components/base/toast' import useRefreshToken from '@/hooks/use-refresh-token' - -type IState = { - formValid: boolean - github: boolean - google: boolean -} - -type IAction = { - type: 'login' | 'login_failed' | 'github_login' | 'github_login_failed' | 'google_login' | 'google_login_failed' -} - -function reducer(state: IState, action: IAction) { - switch (action.type) { - case 'login': - return { - ...state, - formValid: true, - } - case 'login_failed': - return { - ...state, - formValid: true, - } - case 'github_login': - return { - ...state, - github: true, - } - case 'github_login_failed': - return { - ...state, - github: false, - } - case 'google_login': - return { - ...state, - google: true, - } - case 'google_login_failed': - return { - ...state, - google: false, - } - default: - throw new Error('Unknown action.') - } -} +import { IS_CE_EDITION } from '@/config' const NormalForm = () => { - const { t } = useTranslation() const { getNewAccessToken } = useRefreshToken() - const useEmailLogin = IS_CE_EDITION || SUPPORT_MAIL_LOGIN - + const { t } = useTranslation() const router = useRouter() + const searchParams = useSearchParams() + const consoleToken = decodeURIComponent(searchParams.get('access_token') || '') + const refreshToken = decodeURIComponent(searchParams.get('refresh_token') || '') + const message = decodeURIComponent(searchParams.get('message') || '') + const invite_token = decodeURIComponent(searchParams.get('invite_token') || '') + const [isLoading, setIsLoading] = useState(true) + const [systemFeatures, setSystemFeatures] = useState(defaultSystemFeatures) + const [authType, updateAuthType] = useState<'code' | 'password'>('password') + const [showORLine, setShowORLine] = useState(false) + const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false) + const [workspaceName, setWorkSpaceName] = useState('') - const [state, dispatch] = useReducer(reducer, { - formValid: false, - github: false, - google: false, - }) + const isInviteLink = Boolean(invite_token && invite_token !== 'null') - const [showPassword, setShowPassword] = useState(false) - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - - const [isLoading, setIsLoading] = useState(false) - const handleEmailPasswordLogin = async () => { - if (!emailRegex.test(email)) { - Toast.notify({ - type: 'error', - message: t('login.error.emailInValid'), - }) - return - } + const init = useCallback(async () => { try { - setIsLoading(true) - const res = await login({ - url: '/login', - body: { - email, - password, - remember_me: true, - }, - }) - if (res.result === 'success') { - localStorage.setItem('console_token', res.data.access_token) - localStorage.setItem('refresh_token', res.data.refresh_token) + if (consoleToken && refreshToken) { + localStorage.setItem('console_token', consoleToken) + localStorage.setItem('refresh_token', refreshToken) getNewAccessToken() router.replace('/apps') + return } - else { + + if (message) { Toast.notify({ type: 'error', - message: res.data, + message, }) } + const features = await getSystemFeatures() + const allFeatures = { ...defaultSystemFeatures, ...features } + setSystemFeatures(allFeatures) + setAllMethodsAreDisabled(!allFeatures.enable_social_oauth_login && !allFeatures.enable_email_code_login && !allFeatures.enable_email_password_login && !allFeatures.sso_enforced_for_signin) + setShowORLine((allFeatures.enable_social_oauth_login || allFeatures.sso_enforced_for_signin) && (allFeatures.enable_email_code_login || allFeatures.enable_email_password_login)) + updateAuthType(allFeatures.enable_email_password_login ? 'password' : 'code') + if (isInviteLink) { + const checkRes = await invitationCheck({ + url: '/activate/check', + params: { + token: invite_token, + }, + }) + setWorkSpaceName(checkRes?.data?.workspace_name || '') + } } - finally { - setIsLoading(false) + catch (error) { + console.error(error) + setAllMethodsAreDisabled(true) + setSystemFeatures(defaultSystemFeatures) } + finally { setIsLoading(false) } + }, [consoleToken, refreshToken, message, router, invite_token, isInviteLink, getNewAccessToken]) + useEffect(() => { + init() + }, [init]) + if (isLoading || consoleToken) { + return
+ +
} - const { data: github, error: github_error } = useSWR(state.github - ? ({ - url: '/oauth/login/github', - // params: { - // provider: 'github', - // }, - }) - : null, oauth) - - const { data: google, error: google_error } = useSWR(state.google - ? ({ - url: '/oauth/login/google', - // params: { - // provider: 'google', - // }, - }) - : null, oauth) - - useEffect(() => { - if (github_error !== undefined) - dispatch({ type: 'github_login_failed' }) - if (github) - window.location.href = github.redirect_url - }, [github, github_error]) - - useEffect(() => { - if (google_error !== undefined) - dispatch({ type: 'google_login_failed' }) - if (google) - window.location.href = google.redirect_url - }, [google, google_error]) - return ( <> -
-

{t('login.pageTitle')}

-

{t('login.welcome')}

-
-
-
- {!useEmailLogin && ( -
- - + {isInviteLink + ?
+

{t('login.join')}{workspaceName}

+

{t('login.joinTipStart')}{workspaceName}{t('login.joinTipEnd')}

+
+ :
+

{t('login.pageTitle')}

+

{t('login.welcome')}

+
} +
+
+ {systemFeatures.enable_social_oauth_login && } + {systemFeatures.sso_enforced_for_signin &&
+ +
} +
+ + {showORLine &&
+ - )} - +
+ {t('login.or')} +
+
} { - useEmailLogin && <> - {/*
- */} - -
{ }}> -
- -
- setEmail(e.target.value)} - id="email" - type="email" - autoComplete="email" - placeholder={t('login.emailPlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm'} - tabIndex={1} - /> -
-
- -
- -
- setPassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') - handleEmailPasswordLogin() - }} - type={showPassword ? 'text' : 'password'} - autoComplete="current-password" - placeholder={t('login.passwordPlaceholder') || ''} - className={'appearance-none block w-full rounded-lg pl-[14px] px-3 py-2 border border-gray-200 hover:border-gray-300 hover:shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 placeholder-gray-400 caret-primary-600 sm:text-sm pr-10'} - tabIndex={2} - /> -
- -
-
-
- -
- -
-
+ (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <> + {systemFeatures.enable_email_code_login && authType === 'code' && <> + + {systemFeatures.enable_email_password_login &&
{ updateAuthType('password') }}> + {t('login.usePassword')} +
} + } + {systemFeatures.enable_email_password_login && authType === 'password' && <> + + {systemFeatures.enable_email_code_login &&
{ updateAuthType('code') }}> + {t('login.useVerificationCode')} +
} + } } - {/* agree to our Terms and Privacy Policy. */} -
+ {allMethodsAreDisabled && <> +
+
+ +
+

{t('login.noLoginMethod')}

+

{t('login.noLoginMethodTip')}

+
+
+ +
+ } +
{t('login.tosDesc')}   {t('login.tos')}  &  {t('login.pp')}
- - {IS_CE_EDITION &&
+ {IS_CE_EDITION &&
{t('login.goToInit')}   {t('login.setAdminAccount')}
} diff --git a/web/app/signin/oneMoreStep.tsx b/web/app/signin/oneMoreStep.tsx index a4324517a5..8554b364c0 100644 --- a/web/app/signin/oneMoreStep.tsx +++ b/web/app/signin/oneMoreStep.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useReducer } from 'react' import { useTranslation } from 'react-i18next' import Link from 'next/link' import useSWR from 'swr' -import { useRouter } from 'next/navigation' -// import { useContext } from 'use-context-selector' +import { useRouter, useSearchParams } from 'next/navigation' +import Input from '../components/base/input' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' import { SimpleSelect } from '@/app/components/base/select' @@ -12,7 +12,6 @@ import { timezones } from '@/utils/timezone' import { LanguagesSupported, languages } from '@/i18n/language' import { oneMoreStep } from '@/service/common' import Toast from '@/app/components/base/toast' -// import I18n from '@/context/i18n' type IState = { formState: 'processing' | 'error' | 'success' | 'initial' @@ -46,11 +45,11 @@ const reducer = (state: IState, action: any) => { const OneMoreStep = () => { const { t } = useTranslation() const router = useRouter() - // const { locale } = useContext(I18n) + const searchParams = useSearchParams() const [state, dispatch] = useReducer(reducer, { formState: 'initial', - invitation_code: '', + invitation_code: searchParams.get('invitation_code') || '', interface_language: 'en-US', timezone: 'Asia/Shanghai', }) @@ -77,36 +76,35 @@ const OneMoreStep = () => { return ( <>
-

{t('login.oneMoreStep')}

-

{t('login.createSample')}

+

{t('login.oneMoreStep')}

+

{t('login.createSample')}

-
-