Compare commits

..

9 Commits

57 changed files with 1205 additions and 302 deletions

View File

@ -3,7 +3,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from constants.languages import languages
from constants.languages import get_valid_language, languages
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.auth.error import (
@ -15,11 +15,12 @@ from controllers.console.auth.error import (
PasswordMismatchError,
)
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
from libs.password import valid_password
from models import Account
from services.account_service import AccountService
from services.billing_service import BillingService
from services.errors.account import AccountNotFoundError, AccountRegisterError
from services.errors.account import AccountRegisterError
from ..error import AccountInFreezeError, EmailSendIpLimitError
from ..wraps import email_password_login_enabled, email_register_enabled, setup_required
@ -40,12 +41,21 @@ class EmailRegisterResetPayload(BaseModel):
token: str = Field(...)
new_password: str = Field(...)
password_confirm: str = Field(...)
language: str | None = Field(default=None)
timezone: str | None = Field(default=None)
@field_validator("new_password", "password_confirm")
@classmethod
def validate_password(cls, value: str) -> str:
return valid_password(value)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str | None:
if value is None:
return None
return validate_timezone_string(value)
register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload)
@ -144,26 +154,32 @@ class EmailRegisterResetApi(Resource):
if account:
raise EmailAlreadyInUseError()
else:
account = self._create_new_account(normalized_email, args.password_confirm)
if not account:
raise AccountNotFoundError()
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
account = self._create_new_account(
email=normalized_email,
password=args.password_confirm,
timezone=args.timezone,
language=args.language,
)
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
return {"result": "success", "data": token_pair.model_dump()}
def _create_new_account(self, email: str, password: str) -> Account | None:
# Create new account if allowed
account = None
def _create_new_account(
self,
email: str,
password: str,
timezone: str | None = None,
language: str | None = None,
) -> Account:
try:
account = AccountService.create_account_and_tenant(
return AccountService.create_account_and_tenant(
email=email,
name=email,
password=password,
interface_language=languages[0],
interface_language=get_valid_language(language),
timezone=timezone,
)
except AccountRegisterError:
raise AccountInFreezeError()
return account

View File

@ -3,7 +3,7 @@ import logging
import flask_login
from flask import make_response, request
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import Unauthorized
import services
@ -34,6 +34,7 @@ from controllers.console.wraps import (
)
from events.tenant_event import tenant_was_created
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
from libs.login import current_account_with_tenant
from libs.token import (
clear_access_token_from_cookie,
@ -69,6 +70,14 @@ class EmailCodeLoginPayload(BaseModel):
code: str = Field(...)
token: str = Field(...)
language: str | None = Field(default=None)
timezone: str | None = Field(default=None)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str | None:
if value is None:
return None
return validate_timezone_string(value)
register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload)
@ -288,6 +297,7 @@ class EmailCodeLoginApi(Resource):
email=user_email,
name=user_email,
interface_language=get_valid_language(language),
timezone=args.timezone,
)
except WorkSpaceNotAllowedCreateError:
raise NotAllowedCreateWorkspace()

View File

@ -12,7 +12,8 @@ from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import extract_remote_ip
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
from libs.helper import timezone as validate_timezone_string
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo, decode_oauth_state
from libs.token import (
set_access_token_to_cookie,
set_csrf_token_to_cookie,
@ -53,6 +54,31 @@ def get_oauth_providers():
return OAUTH_PROVIDERS
def _validated_timezone(value: str | None) -> str | None:
if not value:
return None
try:
return validate_timezone_string(value)
except ValueError:
return None
def _validated_language(value: str | None) -> str | None:
if value and value in languages:
return value
return None
def _preferred_interface_language(language: str | None = None) -> str:
if language:
return language
preferred_lang = request.accept_languages.best_match(languages)
if preferred_lang and preferred_lang in languages:
return preferred_lang
return languages[0]
@console_ns.route("/oauth/login/<provider>")
class OAuthLogin(Resource):
@console_ns.doc("oauth_login")
@ -64,13 +90,19 @@ class OAuthLogin(Resource):
@console_ns.response(400, "Invalid provider")
def get(self, provider: str):
invite_token = request.args.get("invite_token") or None
timezone = _validated_timezone(request.args.get("timezone") or None)
language = _validated_language(request.args.get("language") or None)
OAUTH_PROVIDERS = get_oauth_providers()
with current_app.app_context():
oauth_provider = OAUTH_PROVIDERS.get(provider)
if not oauth_provider:
return {"error": "Invalid provider"}, 400
auth_url = oauth_provider.get_authorization_url(invite_token=invite_token)
auth_url = oauth_provider.get_authorization_url(
invite_token=invite_token,
timezone=timezone,
language=language,
)
return redirect(auth_url)
@ -96,9 +128,10 @@ class OAuthCallback(Resource):
code = request.args.get("code")
state = request.args.get("state")
invite_token = None
if state:
invite_token = state
oauth_state = decode_oauth_state(state)
invite_token = oauth_state.get("invite_token")
timezone = _validated_timezone(oauth_state.get("timezone"))
language = _validated_language(oauth_state.get("language"))
if not code:
return {"error": "Authorization code is required"}, 400
@ -129,7 +162,7 @@ class OAuthCallback(Resource):
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
try:
account, oauth_new_user = _generate_account(provider, user_info)
account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language)
except AccountNotFoundError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
@ -184,7 +217,12 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
return account
def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]:
def _generate_account(
provider: str,
user_info: OAuthUserInfo,
timezone: str | None = None,
language: str | None = None,
) -> tuple[Account, bool]:
# Get account by openid or email.
account = _get_account_by_openid_or_email(provider, user_info)
oauth_new_user = False
@ -211,26 +249,19 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account,
"30 days and is temporarily unavailable for new account registration"
)
)
else:
raise AccountRegisterError(description=("Invalid email or password"))
raise AccountRegisterError(description=("Invalid email or password"))
account_name = user_info.name or "Dify"
interface_language = _preferred_interface_language(language)
account = RegisterService.register(
email=normalized_email,
name=account_name,
password=None,
open_id=user_info.id,
provider=provider,
language=interface_language,
timezone=timezone,
)
# Set interface language
preferred_lang = request.accept_languages.best_match(languages)
if preferred_lang and preferred_lang in languages:
interface_language = preferred_lang
else:
interface_language = languages[0]
account.interface_language = interface_language
db.session.commit()
# Link account
AccountService.link_account_integrate(provider, user_info.id, account)

View File

@ -1,3 +1,6 @@
import base64
import binascii
import json
import logging
import urllib.parse
from dataclasses import dataclass
@ -27,6 +30,12 @@ class AccessTokenResponse(TypedDict, total=False):
access_token: str
class OAuthState(TypedDict, total=False):
invite_token: str
timezone: str
language: str
class GitHubEmailRecord(TypedDict, total=False):
email: str
primary: bool
@ -46,6 +55,7 @@ class GoogleRawUserInfo(TypedDict):
ACCESS_TOKEN_RESPONSE_ADAPTER = TypeAdapter(AccessTokenResponse)
OAUTH_STATE_ADAPTER = TypeAdapter(OAuthState)
GITHUB_RAW_USER_INFO_ADAPTER = TypeAdapter(GitHubRawUserInfo)
GITHUB_EMAIL_RECORDS_ADAPTER = TypeAdapter(list[GitHubEmailRecord])
GOOGLE_RAW_USER_INFO_ADAPTER = TypeAdapter(GoogleRawUserInfo)
@ -58,6 +68,37 @@ class OAuthUserInfo:
email: str
def encode_oauth_state(
invite_token: str | None = None,
timezone: str | None = None,
language: str | None = None,
) -> str | None:
state: OAuthState = {}
if invite_token:
state["invite_token"] = invite_token
if timezone:
state["timezone"] = timezone
if language:
state["language"] = language
if not state:
return None
raw_state = json.dumps(state, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw_state).decode("ascii").rstrip("=")
def decode_oauth_state(state: str | None) -> OAuthState:
if not state:
return {}
try:
padded_state = state + "=" * (-len(state) % 4)
raw_state = base64.urlsafe_b64decode(padded_state.encode("ascii")).decode("utf-8")
return OAUTH_STATE_ADAPTER.validate_python(json.loads(raw_state))
except (binascii.Error, ValueError, UnicodeDecodeError, json.JSONDecodeError, ValidationError):
return {}
def _json_object(response: httpx.Response) -> JsonObject:
return JSON_OBJECT_ADAPTER.validate_python(response.json())
@ -76,7 +117,12 @@ class OAuth:
self.client_secret = client_secret
self.redirect_uri = redirect_uri
def get_authorization_url(self, invite_token: str | None = None) -> str:
def get_authorization_url(
self,
invite_token: str | None = None,
timezone: str | None = None,
language: str | None = None,
) -> str:
raise NotImplementedError()
def get_access_token(self, code: str) -> str:
@ -99,14 +145,20 @@ class GitHubOAuth(OAuth):
_USER_INFO_URL = "https://api.github.com/user"
_EMAIL_INFO_URL = "https://api.github.com/user/emails"
def get_authorization_url(self, invite_token: str | None = None) -> str:
def get_authorization_url(
self,
invite_token: str | None = None,
timezone: str | None = None,
language: str | None = None,
) -> str:
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": "user:email", # Request only basic user information
}
if invite_token:
params["state"] = invite_token
state = encode_oauth_state(invite_token=invite_token, timezone=timezone, language=language)
if state:
params["state"] = state
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
def get_access_token(self, code: str) -> str:
@ -186,15 +238,21 @@ class GoogleOAuth(OAuth):
_TOKEN_URL = "https://oauth2.googleapis.com/token"
_USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
def get_authorization_url(self, invite_token: str | None = None) -> str:
def get_authorization_url(
self,
invite_token: str | None = None,
timezone: str | None = None,
language: str | None = None,
) -> str:
params = {
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": "openid email",
}
if invite_token:
params["state"] = invite_token
state = encode_oauth_state(invite_token=invite_token, timezone=timezone, language=language)
if state:
params["state"] = state
return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}"
def get_access_token(self, code: str) -> str:

View File

@ -11596,6 +11596,7 @@ Request payload for bulk downloading documents as a zip archive.
| code | string | | Yes |
| email | string | | Yes |
| language | | | No |
| timezone | | | No |
| token | string | | Yes |
#### EmailPayload
@ -11609,8 +11610,10 @@ Request payload for bulk downloading documents as a zip archive.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| language | | | No |
| new_password | string | | Yes |
| password_confirm | string | | Yes |
| timezone | | | No |
| token | string | | Yes |
#### EmailRegisterSendPayload

View File

@ -29,6 +29,7 @@ from extensions.ext_database import db
from extensions.ext_redis import redis_client, redis_fallback
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter, TokenManager
from libs.helper import timezone as validate_timezone
from libs.passport import PassportService
from libs.password import compare_password, hash_password, valid_password
from libs.rsa import generate_key_pair
@ -271,8 +272,9 @@ class AccountService:
password: str | None = None,
interface_theme: str = "light",
is_setup: bool | None = False,
timezone: str | None = None,
) -> Account:
"""create account"""
"""Create an account, preferring explicit user timezone over language-derived defaults."""
if not FeatureService.get_system_features().is_allow_register and not is_setup:
from controllers.console.error import AccountNotFound
@ -302,6 +304,10 @@ class AccountService:
password_to_set = base64_password_hashed
salt_to_set = base64_salt
resolved_timezone = language_timezone_mapping.get(interface_language, "UTC")
if timezone is not None:
resolved_timezone = validate_timezone(timezone)
account = Account(
name=name,
email=email,
@ -309,7 +315,7 @@ class AccountService:
password_salt=salt_to_set,
interface_language=interface_language,
interface_theme=interface_theme,
timezone=language_timezone_mapping.get(interface_language, "UTC"),
timezone=resolved_timezone,
)
db.session.add(account)
@ -318,11 +324,15 @@ class AccountService:
@staticmethod
def create_account_and_tenant(
email: str, name: str, interface_language: str, password: str | None = None
email: str, name: str, interface_language: str, password: str | None = None, timezone: str | None = None
) -> Account:
"""create account"""
"""Create an account and owner workspace."""
account = AccountService.create_account(
email=email, name=name, interface_language=interface_language, password=password
email=email,
name=name,
interface_language=interface_language,
password=password,
timezone=timezone,
)
try:
@ -1474,8 +1484,8 @@ class RegisterService:
@classmethod
def register(
cls,
email,
name,
email: str,
name: str,
password: str | None = None,
open_id: str | None = None,
provider: str | None = None,
@ -1483,16 +1493,19 @@ class RegisterService:
status: AccountStatus | None = None,
is_setup: bool | None = False,
create_workspace_required: bool | None = True,
timezone: str | None = None,
) -> Account:
db.session.begin_nested()
"""Register account"""
db.session.begin_nested()
try:
interface_language = get_valid_language(language)
account = AccountService.create_account(
email=email,
name=name,
interface_language=get_valid_language(language),
interface_language=interface_language,
password=password,
is_setup=is_setup,
timezone=timezone,
)
account.status = status or AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()

View File

@ -143,7 +143,118 @@ class TestEmailRegisterResetApi:
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with("invitee@example.com", "ValidPass123!")
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone=None,
language=None,
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_reset_passes_timezone_to_new_account(
self,
mock_extract_ip,
mock_get_data,
mock_revoke_token,
mock_get_account,
mock_create_account,
mock_login,
mock_reset_login_rate,
app: Flask,
):
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
mock_create_account.return_value = MagicMock()
token_pair = MagicMock()
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"timezone": "Asia/Shanghai",
},
):
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone="Asia/Shanghai",
language=None,
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()
@patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit")
@patch("controllers.console.auth.email_register.AccountService.login")
@patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account")
@patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token")
@patch("controllers.console.auth.email_register.AccountService.get_email_register_data")
@patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1")
def test_reset_passes_language_to_new_account(
self,
mock_extract_ip,
mock_get_data,
mock_revoke_token,
mock_get_account,
mock_create_account,
mock_login,
mock_reset_login_rate,
app: Flask,
):
mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"}
mock_create_account.return_value = MagicMock()
token_pair = MagicMock()
token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"}
mock_login.return_value = token_pair
mock_get_account.return_value = None
feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True)
with (
patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")),
patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags),
):
with app.test_request_context(
"/email-register",
method="POST",
json={
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"language": "zh-Hans",
},
):
response = EmailRegisterResetApi().post()
assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}}
mock_create_account.assert_called_once_with(
email="invitee@example.com",
password="ValidPass123!",
timezone=None,
language="zh-Hans",
)
mock_reset_login_rate.assert_called_once_with("invitee@example.com")
mock_revoke_token.assert_called_once_with("token-123")
mock_extract_ip.assert_called_once()

View File

@ -14,7 +14,7 @@ from controllers.console.auth.oauth import (
_get_account_by_openid_or_email,
get_oauth_providers,
)
from libs.oauth import OAuthUserInfo
from libs.oauth import OAuthUserInfo, encode_oauth_state
from models.account import AccountStatus
from services.account_service import AccountService
from services.errors.account import AccountRegisterError
@ -101,7 +101,55 @@ class TestOAuthLogin:
with app.test_request_context(f"/auth/oauth/github?{query_string}"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(invite_token=expected_token)
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=expected_token,
timezone=None,
language=None,
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth.redirect")
def test_should_pass_timezone_to_oauth_state(
self,
mock_redirect,
mock_get_providers,
resource,
app: Flask,
mock_oauth_provider,
):
mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None}
with app.test_request_context("/auth/oauth/github?timezone=Asia/Shanghai"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone="Asia/Shanghai",
language=None,
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@patch("controllers.console.auth.oauth.get_oauth_providers")
@patch("controllers.console.auth.oauth.redirect")
def test_should_pass_language_to_oauth_state(
self,
mock_redirect,
mock_get_providers,
resource,
app: Flask,
mock_oauth_provider,
):
mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None}
with app.test_request_context("/auth/oauth/github?language=zh-Hans"):
resource.get("github")
mock_oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone=None,
language="zh-Hans",
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
@pytest.mark.parametrize(
@ -229,7 +277,8 @@ class TestOAuthCallback:
mock_register_service.is_valid_invite_token.return_value = True
mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"}
with app.test_request_context("/auth/oauth/github/callback?code=test_code&state=invite123"):
state = encode_oauth_state(invite_token="invite123", timezone="Asia/Shanghai")
with app.test_request_context(f"/auth/oauth/github/callback?code=test_code&state={state}"):
resource.get("github")
mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123")
@ -488,7 +537,13 @@ class TestAccountGeneration:
if should_create:
mock_register_service.register.assert_called_once_with(
email="test@example.com", name="Test User", password=None, open_id="123", provider="github"
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="en-US",
timezone=None,
)
else:
mock_register_service.register.assert_not_called()
@ -515,7 +570,75 @@ class TestAccountGeneration:
_generate_account("github", user_info)
mock_register_service.register.assert_called_once_with(
email="upper@example.com", name="Test User", password=None, open_id="123", provider="github"
email="upper@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="en-US",
timezone=None,
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.AccountService")
@patch("controllers.console.auth.oauth.TenantService")
def test_should_register_with_browser_timezone(
self,
mock_tenant_service,
mock_account_service,
mock_register_service,
mock_feature_service,
mock_get_account,
app: Flask,
user_info,
):
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_register_service.register.return_value = MagicMock()
with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}):
_generate_account("github", user_info, timezone="Asia/Shanghai")
mock_register_service.register.assert_called_once_with(
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="zh-Hans",
timezone="Asia/Shanghai",
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.AccountService")
@patch("controllers.console.auth.oauth.TenantService")
def test_should_register_with_state_language(
self,
mock_tenant_service,
mock_account_service,
mock_register_service,
mock_feature_service,
mock_get_account,
app: Flask,
user_info,
):
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_register_service.register.return_value = MagicMock()
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
_generate_account("github", user_info, language="zh-Hans")
mock_register_service.register.assert_called_once_with(
email="test@example.com",
name="Test User",
password=None,
open_id="123",
provider="github",
language="zh-Hans",
timezone=None,
)
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email")

View File

@ -0,0 +1,40 @@
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload
@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant")
def test_create_new_account_uses_requested_language(mock_create_account):
account = MagicMock()
mock_create_account.return_value = account
result = EmailRegisterResetApi()._create_new_account(
"invitee@example.com",
"ValidPass123!",
timezone="Asia/Shanghai",
language="zh-Hans",
)
assert result is account
mock_create_account.assert_called_once_with(
email="invitee@example.com",
name="invitee@example.com",
password="ValidPass123!",
interface_language="zh-Hans",
timezone="Asia/Shanghai",
)
def test_reset_payload_rejects_invalid_timezone():
with pytest.raises(ValidationError):
EmailRegisterResetPayload.model_validate(
{
"token": "token-123",
"new_password": "ValidPass123!",
"password_confirm": "ValidPass123!",
"timezone": "",
}
)

View File

@ -13,9 +13,10 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError
from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi
from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginPayload, EmailCodeLoginSendEmailApi
from controllers.console.error import (
AccountInFreezeError,
AccountNotFound,
@ -31,6 +32,18 @@ def encode_code(code: str) -> str:
return base64.b64encode(code.encode("utf-8")).decode()
def test_email_code_login_payload_rejects_invalid_timezone():
with pytest.raises(ValidationError):
EmailCodeLoginPayload.model_validate(
{
"email": "newuser@example.com",
"code": "123456",
"token": "token-123",
"timezone": "",
}
)
class TestEmailCodeLoginSendEmailApi:
"""Test cases for sending email verification codes."""
@ -342,6 +355,7 @@ class TestEmailCodeLoginApi:
"code": encode_code("123456"),
"token": "valid_token",
"language": "en-US",
"timezone": "Asia/Shanghai",
},
):
api = EmailCodeLoginApi()
@ -349,7 +363,12 @@ class TestEmailCodeLoginApi:
# Assert
assert response.json["result"] == "success"
mock_create_account.assert_called_once()
mock_create_account.assert_called_once_with(
email="newuser@example.com",
name="newuser@example.com",
interface_language="en-US",
timezone="Asia/Shanghai",
)
@patch("controllers.console.wraps.db")
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")

View File

@ -0,0 +1,123 @@
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console.auth.oauth import OAuthLogin, _generate_account
from libs.oauth import OAuthUserInfo
from services.errors.account import AccountRegisterError
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
@patch("controllers.console.auth.oauth.redirect")
@patch("controllers.console.auth.oauth.get_oauth_providers")
def test_oauth_login_passes_language_and_timezone_to_authorization_url(
mock_get_oauth_providers,
mock_redirect,
app: Flask,
):
oauth_provider = MagicMock()
oauth_provider.get_authorization_url.return_value = "https://github.com/login/oauth/authorize?state=..."
mock_get_oauth_providers.return_value = {"github": oauth_provider}
with app.test_request_context("/oauth/login/github?language=zh-Hans&timezone=Asia/Shanghai"):
OAuthLogin().get("github")
oauth_provider.get_authorization_url.assert_called_once_with(
invite_token=None,
timezone="Asia/Shanghai",
language="zh-Hans",
)
mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?state=...")
@patch("controllers.console.auth.oauth.AccountService.link_account_integrate")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_registers_with_browser_timezone(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_link_account,
app: Flask,
):
account = MagicMock()
mock_register_service.register.return_value = account
mock_feature_service.get_system_features.return_value.is_allow_register = True
user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com")
with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}):
result, oauth_new_user = _generate_account("github", user_info, timezone="Asia/Shanghai")
assert result is account
assert oauth_new_user is True
mock_register_service.register.assert_called_once_with(
email="user@example.com",
name="Test User",
password=None,
open_id="github-123",
provider="github",
language="zh-Hans",
timezone="Asia/Shanghai",
)
mock_link_account.assert_called_once_with("github", "github-123", account)
@patch("controllers.console.auth.oauth.AccountService.link_account_integrate")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_prefers_state_language_over_accept_language(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_link_account,
app: Flask,
):
account = MagicMock()
mock_register_service.register.return_value = account
mock_feature_service.get_system_features.return_value.is_allow_register = True
user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com")
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
_generate_account("github", user_info, language="zh-Hans")
mock_register_service.register.assert_called_once_with(
email="user@example.com",
name="Test User",
password=None,
open_id="github-123",
provider="github",
language="zh-Hans",
timezone=None,
)
mock_link_account.assert_called_once_with("github", "github-123", account)
@patch("controllers.console.auth.oauth.dify_config")
@patch("controllers.console.auth.oauth.RegisterService")
@patch("controllers.console.auth.oauth.FeatureService")
@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None)
def test_generate_account_rejects_new_user_when_registration_disabled(
mock_get_account,
mock_feature_service,
mock_register_service,
mock_config,
app: Flask,
):
mock_feature_service.get_system_features.return_value.is_allow_register = False
mock_config.BILLING_ENABLED = False
user_info = OAuthUserInfo(id="github-123", name="Test User", email="user@example.com")
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
with pytest.raises(AccountRegisterError):
_generate_account("github", user_info)
mock_register_service.register.assert_not_called()

View File

@ -1,6 +1,6 @@
import pytest
from libs.oauth import OAuth
from libs.oauth import OAuth, decode_oauth_state, encode_oauth_state
def test_oauth_base_methods_raise_not_implemented():
@ -17,3 +17,17 @@ def test_oauth_base_methods_raise_not_implemented():
with pytest.raises(NotImplementedError):
oauth._transform_user_info({})
def test_oauth_state_round_trips_invite_token_timezone_and_language():
state = encode_oauth_state(invite_token="invite-123", timezone="Asia/Shanghai", language="zh-Hans")
assert decode_oauth_state(state) == {
"invite_token": "invite-123",
"timezone": "Asia/Shanghai",
"language": "zh-Hans",
}
def test_oauth_state_returns_empty_payload_for_invalid_state():
assert decode_oauth_state("invalid-state") == {}

View File

@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo, decode_oauth_state
class BaseOAuthTest:
@ -37,15 +37,25 @@ class TestGitHubOAuth(BaseOAuthTest):
return GitHubOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
@pytest.mark.parametrize(
("invite_token", "expected_state"),
("invite_token", "timezone", "language", "expected_state"),
[
(None, None),
("test_invite_token", "test_invite_token"),
("", None),
(None, None, None, None),
("test_invite_token", None, None, {"invite_token": "test_invite_token"}),
("", None, None, None),
(None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}),
(None, None, "zh-Hans", {"language": "zh-Hans"}),
(
"test_invite_token",
"Asia/Shanghai",
"zh-Hans",
{"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"},
),
],
)
def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
url = oauth.get_authorization_url(invite_token)
def test_should_generate_authorization_url_correctly(
self, oauth, oauth_config, invite_token, timezone, language, expected_state
):
url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language)
parsed, params = self.parse_auth_url(url)
assert parsed.scheme == "https"
@ -56,7 +66,7 @@ class TestGitHubOAuth(BaseOAuthTest):
assert params["scope"][0] == "user:email"
if expected_state:
assert params["state"][0] == expected_state
assert decode_oauth_state(params["state"][0]) == expected_state
else:
assert "state" not in params
@ -208,15 +218,25 @@ class TestGoogleOAuth(BaseOAuthTest):
return GoogleOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
@pytest.mark.parametrize(
("invite_token", "expected_state"),
("invite_token", "timezone", "language", "expected_state"),
[
(None, None),
("test_invite_token", "test_invite_token"),
("", None),
(None, None, None, None),
("test_invite_token", None, None, {"invite_token": "test_invite_token"}),
("", None, None, None),
(None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}),
(None, None, "zh-Hans", {"language": "zh-Hans"}),
(
"test_invite_token",
"Asia/Shanghai",
"zh-Hans",
{"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"},
),
],
)
def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
url = oauth.get_authorization_url(invite_token)
def test_should_generate_authorization_url_correctly(
self, oauth, oauth_config, invite_token, timezone, language, expected_state
):
url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language)
parsed, params = self.parse_auth_url(url)
assert parsed.scheme == "https"
@ -228,7 +248,7 @@ class TestGoogleOAuth(BaseOAuthTest):
assert params["scope"][0] == "openid email"
if expected_state:
assert params["state"][0] == expected_state
assert decode_oauth_state(params["state"][0]) == expected_state
else:
assert "state" not in params

View File

@ -260,7 +260,7 @@ class TestAccountService:
assert result.interface_theme == "light"
assert result.password is not None
assert result.password_salt is not None
assert result.timezone is not None
assert result.timezone == "America/New_York"
# Verify database operations
mock_db_dependencies["db"].session.add.assert_called_once()
@ -271,7 +271,28 @@ class TestAccountService:
assert added_account.interface_theme == "light"
assert added_account.password is not None
assert added_account.password_salt is not None
assert added_account.timezone is not None
assert added_account.timezone == "America/New_York"
self._assert_database_operations_called(mock_db_dependencies["db"])
def test_create_account_uses_explicit_timezone(
self, mock_db_dependencies, mock_password_dependencies, mock_external_service_dependencies
):
"""Test account creation prefers explicit browser timezone."""
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_password_dependencies["hash_password"].return_value = b"hashed_password"
result = AccountService.create_account(
email="test@example.com",
name="Test User",
interface_language="en-US",
password="password123",
timezone="Asia/Shanghai",
)
assert result.timezone == "Asia/Shanghai"
added_account = mock_db_dependencies["db"].session.add.call_args[0][0]
assert added_account.timezone == "Asia/Shanghai"
self._assert_database_operations_called(mock_db_dependencies["db"])
def test_create_account_registration_disabled(self, mock_external_service_dependencies):
@ -1221,6 +1242,7 @@ class TestRegisterService:
interface_language="en-US",
password="password123",
is_setup=False,
timezone=None,
)
mock_create_tenant.assert_called_once_with("Test User's Workspace")
mock_create_member.assert_called_once_with(mock_tenant, mock_account, role="owner")

View File

@ -334,7 +334,7 @@ describe('CloudPlanItem', () => {
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should show education unsupported warning and switch checkout to professional annual', async () => {
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -355,18 +355,18 @@ describe('CloudPlanItem', () => {
fireEvent.click(button)
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
expect(screen.getByText('education.educationPricingConfirm.description'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument()
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should continue selected plan checkout when keeping current plan', async () => {
it('should close the unsupported plan confirm without checkout when canceled', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -384,31 +384,6 @@ describe('CloudPlanItem', () => {
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when using the close button', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
})

View File

@ -1,14 +1,15 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../../../type'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo } from 'react'
@ -23,7 +24,7 @@ import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import PlanButton from './button'
import Button from './button'
import List from './list'
const ICON_MAP = {
@ -32,6 +33,10 @@ const ICON_MAP = {
[Plan.team]: <Team />,
}
type ConfirmType = {
type: 'info' | 'warning'
}
type CloudPlanItemProps = {
currentPlan: BasicPlan
plan: BasicPlan
@ -59,12 +64,15 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
? t('planNotSupportEducationDiscount', { ns: 'education' })
: undefined
const openAsyncWindow = useAsyncWindowOpen()
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
const btnText = useMemo(() => {
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
@ -131,19 +139,16 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
await handlePayCurrentPlan()
}
const handleSwitchToProfessionalAnnual = async () => {
await handleEducationDiscount()
}
const handleKeepCurrentPlan = async () => {
await handlePayCurrentPlan()
const handleContinueCurrentPlan = async () => {
setShowEducationPricingConfirm(false)
await handlePayCurrentPlan()
}
return (
<div className="flex min-w-0 flex-1 flex-col pb-3">
<div className="flex flex-col px-5 py-4">
<div className="flex flex-col gap-y-6 px-1 pt-10">
{ICON_MAP[plan]}
<div className="flex min-h-26 flex-col gap-y-2">
<div className="flex min-h-[104px] flex-col gap-y-2">
<div className="flex items-center gap-x-2.5">
<div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t(`${i18nPrefix}.name`, { ns: 'billing' })}</div>
{
@ -183,7 +188,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
</>
)}
</div>
<PlanButton
<Button
plan={plan}
isPlanDisabled={isPlanDisabled}
btnText={btnText}
@ -192,49 +197,41 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
/>
</div>
<List plan={plan} />
<Dialog
<AlertDialog
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[520px] overflow-visible"
>
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="top-6 right-6"
/>
<div className="flex flex-col gap-2 pr-10">
<DialogTitle className="w-full title-2xl-semi-bold text-text-primary">
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('educationPricingConfirm.title', { ns: 'education' })}
</DialogTitle>
<DialogDescription className="w-full system-md-regular text-text-tertiary">
{t('educationPricingConfirm.description', { ns: 'education' })}
</DialogDescription>
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('educationPricingConfirm.description', {
ns: 'education',
planName: selectedPlanName,
billingPeriod: selectedBillingPeriod,
})}
</AlertDialogDescription>
</div>
<div className="mt-10 flex items-start justify-end gap-3">
<Button
size="large"
onClick={handleKeepCurrentPlan}
disabled={loading || isEducationDiscountLoading}
loading={loading}
className="min-w-38"
<AlertDialogActions>
<AlertDialogCancelButton
onClick={() => setShowEducationPricingConfirm(false)}
disabled={loading}
>
{t('educationPricingConfirm.cancel', { ns: 'education' })}
</Button>
<Button
variant="primary"
size="large"
onClick={handleSwitchToProfessionalAnnual}
disabled={isEducationDiscountLoading}
loading={isEducationDiscountLoading}
className="min-w-61"
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleContinueCurrentPlan}
disabled={loading}
loading={loading}
>
{t('educationPricingConfirm.continue', { ns: 'education' })}
</Button>
</div>
</DialogContent>
</Dialog>
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -271,12 +271,6 @@ describe('Install Component', () => {
expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto')
})
it('should constrain the install step so the plugin list can scroll with many items', () => {
const { container } = render(<Install {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden')
})
it('should show singular text when one plugin is selected', async () => {
render(<Install {...defaultProps} />)

View File

@ -170,8 +170,8 @@ const Install: FC<Props> = ({
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
return (
<div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3">
<>
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
</div>
@ -218,7 +218,7 @@ const Install: FC<Props> = ({
</div>
)}
</div>
</>
)
}
export default React.memo(Install)

View File

@ -292,12 +292,6 @@ describe('InstallFromLocalPackage', () => {
expect(screen.getByTestId('is-bundle')).toHaveTextContent('true')
})
it('should constrain dialog height so bundle dependency lists can scroll', () => {
render(<InstallFromLocalPackage {...defaultProps} file={createMockBundleFile()} />)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should identify package file correctly', () => {
render(<InstallFromLocalPackage {...defaultProps} />)

View File

@ -93,7 +93,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -212,19 +212,6 @@ describe('InstallFromMarketplace', () => {
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
})
it('should constrain bundle dialog height so dependency lists can scroll', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should pass isFromMarketPlace as true to bundle component', () => {
const dependencies = createMockDependencies()
render(

View File

@ -77,7 +77,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -72,10 +72,10 @@ describe('InfoModal', () => {
expect(screen.getByText('Test App')).toBeInTheDocument()
})
it('should render copyright in the full rights reserved format when provided', async () => {
it('should render copyright when provided', async () => {
const siteInfoWithCopyright: SiteInfo = {
...baseSiteInfo,
copyright: 'Dify AI',
copyright: 'Dify Inc.',
}
await renderModal(
@ -86,8 +86,7 @@ describe('InfoModal', () => {
/>,
)
const currentYear = new Date().getFullYear().toString()
expect(screen.getByText(`Copyright © ${currentYear} Dify AI. All Rights Reserved.`)).toBeInTheDocument()
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
})
it('should render current year in copyright', async () => {

View File

@ -16,8 +16,6 @@ const InfoModal = ({
onClose,
data,
}: Props) => {
const [currentYear] = React.useState(() => new Date().getFullYear())
return (
<Dialog
open={isShow}
@ -26,7 +24,7 @@ const InfoModal = ({
onClose()
}}
>
<DialogContent className="w-full max-w-100 min-w-100 overflow-hidden! border-none p-0! text-left align-middle">
<DialogContent className="w-full max-w-[400px] min-w-[400px] overflow-hidden! border-none p-0! text-left align-middle">
<DialogCloseButton />
<div className={cn('flex flex-col items-center gap-4 px-4 pt-10 pb-8')}>
@ -37,19 +35,15 @@ const InfoModal = ({
background={data?.icon_background || appDefaultIconBackground}
imageUrl={data?.icon_url}
/>
<div className="w-full text-center">
<div className="system-xl-semibold text-text-secondary">{data?.title}</div>
<div className="mt-1 system-xl-medium text-text-tertiary">{data?.description}</div>
</div>
<div className="system-xl-semibold text-text-secondary">{data?.title}</div>
<div className="system-xs-regular text-text-tertiary">
{/* copyright */}
{data?.copyright && (
<div>
Copyright ©
{currentYear}
©
{(new Date()).getFullYear()}
{' '}
{data?.copyright}
. All Rights Reserved.
</div>
)}
{data?.custom_disclaimer && (

View File

@ -13,6 +13,7 @@ import { useLocale } from '@/context/i18n'
import { useRouter, useSearchParams } from '@/next/navigation'
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
import { encryptVerificationCode } from '@/utils/encryption'
import { getBrowserTimezone } from '@/utils/timezone'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
export default function CheckCode() {
@ -39,7 +40,13 @@ export default function CheckCode() {
return
}
setIsLoading(true)
const ret = await emailLoginWithCode({ email, code: encryptVerificationCode(code), token, language })
const ret = await emailLoginWithCode({
email,
code: encryptVerificationCode(code),
token,
language,
timezone: getBrowserTimezone(),
})
if (ret.result === 'success') {
// Track login success event
trackEvent('user_login_success', {

View File

@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocale } from '@/context/i18n'
import { useSearchParams } from '@/next/navigation'
import { getBrowserTimezone } from '@/utils/timezone'
import SocialAuth from '../social-auth'
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(),
}))
vi.mock('@/utils/timezone', () => ({
getBrowserTimezone: vi.fn(),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
const mockUseLocale = vi.mocked(useLocale)
const mockGetBrowserTimezone = vi.mocked(getBrowserTimezone)
describe('SocialAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
mockUseLocale.mockReturnValue('zh-Hans')
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
})
describe('Rendering', () => {
it('should render oauth provider links', () => {
render(<SocialAuth />)
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toBeInTheDocument()
})
})
describe('OAuth params', () => {
it('should include browser timezone and locale in oauth links', () => {
render(<SocialAuth />)
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toHaveAttribute(
'href',
expect.stringContaining('timezone=Asia%2FShanghai'),
)
expect(screen.getByRole('link', { name: 'login.withGitHub' })).toHaveAttribute(
'href',
expect.stringContaining('language=zh-Hans'),
)
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toHaveAttribute(
'href',
expect.stringContaining('timezone=Asia%2FShanghai'),
)
expect(screen.getByRole('link', { name: 'login.withGoogle' })).toHaveAttribute(
'href',
expect.stringContaining('language=zh-Hans'),
)
})
it('should preserve invite token when adding timezone', () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams('invite_token=invite-123') as unknown as ReturnType<typeof useSearchParams>,
)
render(<SocialAuth />)
const githubLink = screen.getByRole('link', { name: 'login.withGitHub' })
expect(githubLink).toHaveAttribute('href', expect.stringContaining('invite_token=invite-123'))
expect(githubLink).toHaveAttribute('href', expect.stringContaining('timezone=Asia%2FShanghai'))
expect(githubLink).toHaveAttribute('href', expect.stringContaining('language=zh-Hans'))
})
})
describe('Edge Cases', () => {
it('should omit timezone when browser timezone is unavailable', () => {
mockGetBrowserTimezone.mockReturnValue(undefined)
render(<SocialAuth />)
expect(screen.getByRole('link', { name: 'login.withGitHub' }).getAttribute('href')).not.toContain('timezone=')
})
})
})

View File

@ -2,8 +2,10 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { API_PREFIX } from '@/config'
import { useLocale } from '@/context/i18n'
import { useSearchParams } from '@/next/navigation'
import { getPurifyHref } from '@/utils'
import { getBrowserTimezone } from '@/utils/timezone'
import style from '../page.module.css'
type SocialAuthProps = {
@ -13,11 +15,19 @@ type SocialAuthProps = {
export default function SocialAuth(props: SocialAuthProps) {
const { t } = useTranslation()
const searchParams = useSearchParams()
const locale = useLocale()
const getOAuthLink = (href: string) => {
const url = getPurifyHref(`${API_PREFIX}${href}`)
if (searchParams.has('invite_token'))
return `${url}?${searchParams.toString()}`
const params = new URLSearchParams(searchParams.toString())
const timezone = getBrowserTimezone()
if (timezone)
params.set('timezone', timezone)
params.set('language', locale)
const query = params.toString()
if (query)
return `${url}?${query}`
return url
}

View File

@ -0,0 +1,139 @@
import type { MockedFunction } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocale } from '@/context/i18n'
import { useRouter, useSearchParams } from '@/next/navigation'
import { activateMember } from '@/service/common'
import { useInvitationCheck } from '@/service/use-common'
import { getBrowserTimezone } from '@/utils/timezone'
import InviteSettingsPage from '../page'
vi.mock('@tanstack/react-query', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
return {
...actual,
useSuspenseQuery: vi.fn(() => ({
data: {
branding: {
enabled: true,
},
},
})),
}
})
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(),
}))
vi.mock('@/i18n-config', () => ({
i18n: {
defaultLocale: 'en-US',
},
setLocaleOnClient: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/service/common', () => ({
activateMember: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useInvitationCheck: vi.fn(),
}))
vi.mock('@/utils/timezone', () => ({
getBrowserTimezone: vi.fn(),
timezones: [
{ value: 'Asia/Shanghai', name: 'Asia/Shanghai' },
{ value: 'America/Los_Angeles', name: 'America/Los_Angeles' },
],
}))
vi.mock('../utils/post-login-redirect', () => ({
resolvePostLoginRedirect: vi.fn(() => null),
}))
const mockReplace = vi.fn()
const mockRefetch = vi.fn()
const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
const mockActivateMember = activateMember as unknown as MockedFunction<typeof activateMember>
const mockUseInvitationCheck = useInvitationCheck as unknown as MockedFunction<typeof useInvitationCheck>
const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone>
describe('InviteSettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseLocale.mockReturnValue('zh-Hans')
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
mockUseSearchParams.mockReturnValue(
new URLSearchParams('invite_token=invite-token') as unknown as ReturnType<typeof useSearchParams>,
)
mockUseInvitationCheck.mockReturnValue({
data: {
is_valid: true,
data: {
workspace_name: 'Acme',
workspace_id: 'workspace-id',
email: 'invitee@example.com',
},
},
refetch: mockRefetch,
} as unknown as ReturnType<typeof useInvitationCheck>)
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
mockActivateMember.mockResolvedValue({ result: 'success' })
})
describe('Activation payload', () => {
it('should default language to the current UI locale', async () => {
render(<InviteSettingsPage />)
fireEvent.change(screen.getByLabelText('login.name'), {
target: { value: 'Invitee' },
})
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
await waitFor(() => {
expect(mockActivateMember).toHaveBeenCalledWith({
url: '/activate',
body: {
token: 'invite-token',
name: 'Invitee',
interface_language: 'zh-Hans',
timezone: 'Asia/Shanghai',
},
})
})
})
it('should fall back to configured default locale when current locale is unsupported', async () => {
mockUseLocale.mockReturnValue('unsupported-locale' as ReturnType<typeof useLocale>)
render(<InviteSettingsPage />)
fireEvent.change(screen.getByLabelText('login.name'), {
target: { value: 'Invitee' },
})
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
await waitFor(() => {
expect(mockActivateMember).toHaveBeenCalledWith({
url: '/activate',
body: {
token: 'invite-token',
name: 'Invitee',
interface_language: 'en-US',
timezone: 'Asia/Shanghai',
},
})
})
})
})
})

View File

@ -11,14 +11,15 @@ import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { LICENSE_LINK } from '@/constants/link'
import { setLocaleOnClient } from '@/i18n-config'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import { useLocale } from '@/context/i18n'
import { i18n, setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
import Link from '@/next/link'
import { useRouter, useSearchParams } from '@/next/navigation'
import { activateMember } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInvitationCheck } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
import { getBrowserTimezone, timezones } from '@/utils/timezone'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
type LanguageSelectOption = {
@ -43,15 +44,23 @@ const TIMEZONE_OPTIONS: TimezoneSelectOption[] = timezones.map(item => ({
name: item.name,
}))
const getInitialLanguage = (locale: Locale): Locale => {
if (LANGUAGE_OPTIONS.some(item => item.value === locale))
return locale
return i18n.defaultLocale
}
export default function InviteSettingsPage() {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('invite_token') as string)
const locale = useLocale()
const [name, setName] = useState('')
const [language, setLanguage] = useState(LanguagesSupported[0])
const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Los_Angeles')
const [language, setLanguage] = useState(() => getInitialLanguage(locale))
const [timezone, setTimezone] = useState(() => getBrowserTimezone() || 'America/Los_Angeles')
const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language)
const selectedTimezone = TIMEZONE_OPTIONS.find(item => item.value === timezone)

View File

@ -0,0 +1,85 @@
import type { MockedFunction } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLocale } from '@/context/i18n'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useMailRegister } from '@/service/use-common'
import { getBrowserTimezone } from '@/utils/timezone'
import ChangePasswordForm from '../page'
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMailRegister: vi.fn(),
}))
vi.mock('@/utils/timezone', () => ({
getBrowserTimezone: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/utils/create-app-tracking', () => ({
rememberCreateAppExternalAttribution: vi.fn(),
}))
const mockRegister = vi.fn()
const mockReplace = vi.fn()
const mockUseLocale = useLocale as unknown as MockedFunction<typeof useLocale>
const mockUseSearchParams = useSearchParams as unknown as MockedFunction<typeof useSearchParams>
const mockUseRouter = useRouter as unknown as MockedFunction<typeof useRouter>
const mockUseMailRegister = useMailRegister as unknown as MockedFunction<typeof useMailRegister>
const mockGetBrowserTimezone = getBrowserTimezone as unknown as MockedFunction<typeof getBrowserTimezone>
describe('Signup Set Password Page', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseLocale.mockReturnValue('zh-Hans')
mockUseSearchParams.mockReturnValue(new URLSearchParams('token=register-token') as unknown as ReturnType<typeof useSearchParams>)
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
mockUseMailRegister.mockReturnValue({
mutateAsync: mockRegister,
isPending: false,
} as unknown as ReturnType<typeof useMailRegister>)
mockGetBrowserTimezone.mockReturnValue('Asia/Shanghai')
mockRegister.mockResolvedValue({ result: 'fail', data: {} })
})
describe('Registration payload', () => {
it('should submit locale and browser timezone when setting password', async () => {
render(<ChangePasswordForm />)
fireEvent.change(screen.getByLabelText('common.account.newPassword'), {
target: { value: 'ValidPass123!' },
})
fireEvent.change(screen.getByLabelText('common.account.confirmPassword'), {
target: { value: 'ValidPass123!' },
})
fireEvent.click(screen.getByRole('button', { name: 'login.changePasswordBtn' }))
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith({
token: 'register-token',
new_password: 'ValidPass123!',
password_confirm: 'ValidPass123!',
language: 'zh-Hans',
timezone: 'Asia/Shanghai',
})
})
})
})
})

View File

@ -9,10 +9,12 @@ import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import { useLocale } from '@/context/i18n'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useMailRegister } from '@/service/use-common'
import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking'
import { sendGAEvent } from '@/utils/gtag'
import { getBrowserTimezone } from '@/utils/timezone'
const parseUtmInfo = () => {
const utmInfoStr = Cookies.get('utm_info')
@ -32,6 +34,7 @@ const ChangePasswordForm = () => {
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('token') || '')
const locale = useLocale()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
@ -65,6 +68,8 @@ const ChangePasswordForm = () => {
token,
new_password: password,
password_confirm: confirmPassword,
language: locale,
timezone: getBrowserTimezone(),
})
const { result } = res as MailRegisterResponse
if (result === 'success') {
@ -88,7 +93,7 @@ const ChangePasswordForm = () => {
catch (error) {
console.error(error)
}
}, [password, token, valid, confirmPassword, register])
}, [password, token, valid, confirmPassword, register, locale])
return (
<div className={

View File

@ -16,10 +16,10 @@
"currentSigned": "تم تسجيل الدخول حاليًا باسم",
"educationPricingConfirm.billingPeriod.monthly": "شهري",
"educationPricingConfirm.billingPeriod.yearly": "سنوي",
"educationPricingConfirm.cancel": "الاحتفاظ بالخطة الحالية",
"educationPricingConfirm.continue": "التبديل إلى Professional السنوية",
"educationPricingConfirm.description": "ينطبق الخصم التعليمي على خطة Professional السنوية فقط. الاحتفاظ بخطتك الحالية لن يتضمن الخصم.",
"educationPricingConfirm.title": "الخطة التي اخترتها لا تدعم الخصم التعليمي",
"educationPricingConfirm.cancel": "إلغاء",
"educationPricingConfirm.continue": "المتابعة بدون خصم",
"educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.",
"educationPricingConfirm.title": "الخصم التعليمي غير متاح",
"emailLabel": "بريدك الإلكتروني الحالي",
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
"form.schoolName.title": "اسم مدرستك",

View File

@ -16,10 +16,10 @@
"currentSigned": "DERZEIT ANGEMELDET ALS",
"educationPricingConfirm.billingPeriod.monthly": "monatlich",
"educationPricingConfirm.billingPeriod.yearly": "jährlich",
"educationPricingConfirm.cancel": "Aktuellen Plan behalten",
"educationPricingConfirm.continue": "Zu Professional jährlich wechseln",
"educationPricingConfirm.description": "Der Bildungsrabatt gilt nur für den jährlichen Professional-Plan. Wenn Sie Ihren aktuellen Plan behalten, ist der Rabatt nicht enthalten.",
"educationPricingConfirm.title": "Ihr ausgewählter Plan unterstützt den Bildungsrabatt nicht",
"educationPricingConfirm.cancel": "Abbrechen",
"educationPricingConfirm.continue": "Ohne Rabatt fortfahren",
"educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.",
"educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar",
"emailLabel": "Ihre aktuelle E-Mail",
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
"form.schoolName.title": "Ihr Schulname",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "monthly",
"educationPricingConfirm.billingPeriod.yearly": "annual",
"educationPricingConfirm.cancel": "Keep current plan",
"educationPricingConfirm.continue": "Switch to Professional Annual",
"educationPricingConfirm.description": "The education discount applies to the Professional annual plan only. Keeping your current plan won't include the discount.",
"educationPricingConfirm.title": "Your selected plan doesn't support the education discount",
"educationPricingConfirm.cancel": "Cancel",
"educationPricingConfirm.continue": "Continue without discount",
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
"educationPricingConfirm.title": "Education discount not available",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensual",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Mantener el plan actual",
"educationPricingConfirm.continue": "Cambiar a Professional anual",
"educationPricingConfirm.description": "El descuento educativo solo se aplica al plan Professional anual. Si mantienes tu plan actual, no se incluirá el descuento.",
"educationPricingConfirm.title": "El plan seleccionado no admite el descuento educativo",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sin descuento",
"educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.",
"educationPricingConfirm.title": "Descuento educativo no disponible",
"emailLabel": "Tu correo electrónico actual",
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
"form.schoolName.title": "El nombre de tu escuela",

View File

@ -16,10 +16,10 @@
"currentSigned": "اکنون به عنوان",
"educationPricingConfirm.billingPeriod.monthly": "ماهانه",
"educationPricingConfirm.billingPeriod.yearly": "سالانه",
"educationPricingConfirm.cancel": "حفظ طرح فعلی",
"educationPricingConfirm.continue": "تغییر به Professional سالانه",
"educationPricingConfirm.description": "تخفیف آموزشی فقط برای طرح سالانه Professional اعمال می‌شود. با حفظ طرح فعلی، این تخفیف شامل نمی‌شود.",
"educationPricingConfirm.title": "طرح انتخاب‌شده شما از تخفیف آموزشی پشتیبانی نمی‌کند",
"educationPricingConfirm.cancel": "لغو",
"educationPricingConfirm.continue": "ادامه بدون تخفیف",
"educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمی‌کند. فقط طرح سالانه Professional واجد شرایط است.",
"educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست",
"emailLabel": "ایمیل فعلی شما",
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
"form.schoolName.title": "نام مدرسه شما",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE",
"educationPricingConfirm.billingPeriod.monthly": "mensuel",
"educationPricingConfirm.billingPeriod.yearly": "annuel",
"educationPricingConfirm.cancel": "Conserver le plan actuel",
"educationPricingConfirm.continue": "Passer à Professional annuel",
"educationPricingConfirm.description": "La remise éducation s'applique uniquement au plan Professional annuel. En conservant votre plan actuel, la remise ne sera pas incluse.",
"educationPricingConfirm.title": "Le plan sélectionné ne prend pas en charge la remise éducation",
"educationPricingConfirm.cancel": "Annuler",
"educationPricingConfirm.continue": "Continuer sans remise",
"educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.",
"educationPricingConfirm.title": "Remise éducative non disponible",
"emailLabel": "Votre email actuel",
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
"form.schoolName.title": "Le nom de votre école",

View File

@ -16,10 +16,10 @@
"currentSigned": "वर्तमान में साइन इन किया गया है के रूप में",
"educationPricingConfirm.billingPeriod.monthly": "मासिक",
"educationPricingConfirm.billingPeriod.yearly": "वार्षिक",
"educationPricingConfirm.cancel": "वर्तमान प्लान रखें",
"educationPricingConfirm.continue": "Professional वार्षिक पर स्विच करें",
"educationPricingConfirm.description": "शिक्षा छूट केवल Professional वार्षिक प्लान पर लागू होती है। अपना वर्तमान प्लान रखने पर छूट शामिल नहीं होगी।",
"educationPricingConfirm.title": "आपका चुना हुआ प्लान शिक्षा छूट का समर्थन नहीं करता",
"educationPricingConfirm.cancel": "रद्द करें",
"educationPricingConfirm.continue": "छूट के बिना जारी रखें",
"educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।",
"educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं",
"emailLabel": "आपका वर्तमान ईमेल",
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
"form.schoolName.title": "आपके स्कूल का नाम",

View File

@ -16,10 +16,10 @@
"currentSigned": "SAAT INI MASUK SEBAGAI",
"educationPricingConfirm.billingPeriod.monthly": "bulanan",
"educationPricingConfirm.billingPeriod.yearly": "tahunan",
"educationPricingConfirm.cancel": "Tetap gunakan paket saat ini",
"educationPricingConfirm.continue": "Beralih ke Professional Tahunan",
"educationPricingConfirm.description": "Diskon pendidikan hanya berlaku untuk paket Professional tahunan. Jika tetap menggunakan paket saat ini, diskon tidak akan disertakan.",
"educationPricingConfirm.title": "Paket yang Anda pilih tidak mendukung diskon pendidikan",
"educationPricingConfirm.cancel": "Batal",
"educationPricingConfirm.continue": "Lanjutkan tanpa diskon",
"educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.",
"educationPricingConfirm.title": "Diskon pendidikan tidak tersedia",
"emailLabel": "Email Anda saat ini",
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
"form.schoolName.title": "Nama Sekolah Anda",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATTUALMENTE ACCEDUTO COME",
"educationPricingConfirm.billingPeriod.monthly": "mensile",
"educationPricingConfirm.billingPeriod.yearly": "annuale",
"educationPricingConfirm.cancel": "Mantieni il piano attuale",
"educationPricingConfirm.continue": "Passa a Professional annuale",
"educationPricingConfirm.description": "Lo sconto Education si applica solo al piano Professional annuale. Mantenendo il piano attuale, lo sconto non verrà incluso.",
"educationPricingConfirm.title": "Il piano selezionato non supporta lo sconto Education",
"educationPricingConfirm.cancel": "Annulla",
"educationPricingConfirm.continue": "Continua senza sconto",
"educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.",
"educationPricingConfirm.title": "Sconto educativo non disponibile",
"emailLabel": "La tua email attuale",
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
"form.schoolName.title": "Il Nome della tua Scuola",

View File

@ -16,10 +16,10 @@
"currentSigned": "現在ログイン中のアカウントは",
"educationPricingConfirm.billingPeriod.monthly": "月次",
"educationPricingConfirm.billingPeriod.yearly": "年次",
"educationPricingConfirm.cancel": "現在のプランを維持",
"educationPricingConfirm.continue": "Professional 年間プランに切り替える",
"educationPricingConfirm.description": "教育割引は Professional 年プランのみ適用されます。現在のプランを維持すると、割引は適用されません。",
"educationPricingConfirm.title": "選択したプランは教育割引に対応していません",
"educationPricingConfirm.cancel": "キャンセル",
"educationPricingConfirm.continue": "割引なしで続行",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年プランのみが対象です。",
"educationPricingConfirm.title": "教育割引は利用できません",
"emailLabel": "現在のメールアドレス",
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
"form.schoolName.title": "学校名",

View File

@ -16,10 +16,10 @@
"currentSigned": "현재 로그인 중입니다",
"educationPricingConfirm.billingPeriod.monthly": "월간",
"educationPricingConfirm.billingPeriod.yearly": "연간",
"educationPricingConfirm.cancel": "현재 플랜 유지",
"educationPricingConfirm.continue": "Professional 연간으로 전환",
"educationPricingConfirm.description": "교육 할인은 Professional 연간 플랜적용됩니다. 현재 플랜을 유지하면 할인이 포함되지 않습니다.",
"educationPricingConfirm.title": "선택한 플랜은 교육 할인을 지원하지 않습니다",
"educationPricingConfirm.cancel": "취소",
"educationPricingConfirm.continue": "할인 없이 계속",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.",
"educationPricingConfirm.title": "교육 할인 불가",
"emailLabel": "현재 이메일",
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
"form.schoolName.title": "당신의 학교 이름",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "maandelijks",
"educationPricingConfirm.billingPeriod.yearly": "jaarlijks",
"educationPricingConfirm.cancel": "Huidig abonnement behouden",
"educationPricingConfirm.continue": "Overschakelen naar Professional jaarlijks",
"educationPricingConfirm.description": "De onderwijskorting is alleen van toepassing op het jaarlijkse Professional-abonnement. Als u uw huidige abonnement behoudt, is de korting niet inbegrepen.",
"educationPricingConfirm.title": "Uw geselecteerde abonnement ondersteunt de onderwijskorting niet",
"educationPricingConfirm.cancel": "Annuleren",
"educationPricingConfirm.continue": "Doorgaan zonder korting",
"educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.",
"educationPricingConfirm.title": "Onderwijskorting niet beschikbaar",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "AKTUALNIE ZALOGOWANY JAKO",
"educationPricingConfirm.billingPeriod.monthly": "miesięcznie",
"educationPricingConfirm.billingPeriod.yearly": "rocznie",
"educationPricingConfirm.cancel": "Zachowaj obecny plan",
"educationPricingConfirm.continue": "Przełącz na Professional roczny",
"educationPricingConfirm.description": "Zniżka edukacyjna dotyczy tylko rocznego planu Professional. Pozostanie przy obecnym planie nie obejmie zniżki.",
"educationPricingConfirm.title": "Wybrany plan nie obsługuje zniżki edukacyjnej",
"educationPricingConfirm.cancel": "Anuluj",
"educationPricingConfirm.continue": "Kontynuuj bez rabatu",
"educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.",
"educationPricingConfirm.title": "Rabat edukacyjny niedostępny",
"emailLabel": "Twój aktualny email",
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
"form.schoolName.title": "Nazwa Twojej Szkoły",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensal",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Manter plano atual",
"educationPricingConfirm.continue": "Mudar para Professional anual",
"educationPricingConfirm.description": "O desconto educacional se aplica apenas ao plano Professional anual. Manter seu plano atual não incluirá o desconto.",
"educationPricingConfirm.title": "O plano selecionado não aceita o desconto educacional",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sem desconto",
"educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.",
"educationPricingConfirm.title": "Desconto educacional não disponível",
"emailLabel": "Seu e-mail atual",
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
"form.schoolName.title": "O nome da sua escola",

View File

@ -16,10 +16,10 @@
"currentSigned": "CONEXIUNE ÎN PREZENT CA",
"educationPricingConfirm.billingPeriod.monthly": "lunar",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Păstrează planul curent",
"educationPricingConfirm.continue": "Treci la Professional anual",
"educationPricingConfirm.description": "Reducerea educațională se aplică doar planului Professional anual. Dacă păstrezi planul curent, reducerea nu va fi inclusă.",
"educationPricingConfirm.title": "Planul selectat nu acceptă reducerea educațională",
"educationPricingConfirm.cancel": "Anulează",
"educationPricingConfirm.continue": "Continuă fără reducere",
"educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.",
"educationPricingConfirm.title": "Reducerea educațională nu este disponibilă",
"emailLabel": "Emailul tău curent",
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
"form.schoolName.title": "Numele Școlii Tale",

View File

@ -16,10 +16,10 @@
"currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК",
"educationPricingConfirm.billingPeriod.monthly": "ежемесячно",
"educationPricingConfirm.billingPeriod.yearly": "ежегодно",
"educationPricingConfirm.cancel": "Оставить текущий план",
"educationPricingConfirm.continue": ерейти на Professional годовой",
"educationPricingConfirm.description": "Образовательная скидка применяется только к годовому плану Professional. Если оставить текущий план, скидка не будет включена.",
"educationPricingConfirm.title": "Выбранный план не поддерживает образовательную скидку",
"educationPricingConfirm.cancel": "Отмена",
"educationPricingConfirm.continue": родолжить без скидки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.",
"educationPricingConfirm.title": "Образовательная скидка недоступна",
"emailLabel": "Ваш текущий адрес электронной почты",
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
"form.schoolName.title": "Название вашей школы",

View File

@ -16,10 +16,10 @@
"currentSigned": "Trenutno prijavljen kot",
"educationPricingConfirm.billingPeriod.monthly": "mesečno",
"educationPricingConfirm.billingPeriod.yearly": "letno",
"educationPricingConfirm.cancel": "Obdrži trenutni paket",
"educationPricingConfirm.continue": "Preklopi na letni Professional",
"educationPricingConfirm.description": "Izobraževalni popust velja samo za letni paket Professional. Če obdržite trenutni paket, popust ne bo vključen.",
"educationPricingConfirm.title": "Izbrani paket ne podpira izobraževalnega popusta",
"educationPricingConfirm.cancel": "Prekliči",
"educationPricingConfirm.continue": "Nadaljuj brez popusta",
"educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.",
"educationPricingConfirm.title": "Izobraževalni popust ni na voljo",
"emailLabel": "Vaš trenutni elektronski naslov",
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
"form.schoolName.title": "Ime vaše šole",

View File

@ -16,10 +16,10 @@
"currentSigned": "ลงชื่อเข้าใช้ในฐานะ",
"educationPricingConfirm.billingPeriod.monthly": "รายเดือน",
"educationPricingConfirm.billingPeriod.yearly": "รายปี",
"educationPricingConfirm.cancel": "ใช้แผนปัจจุบันต่อ",
"educationPricingConfirm.continue": "เปลี่ยนเป็น Professional รายปี",
"educationPricingConfirm.description": "ส่วนลดการศึกษาใช้ได้เฉพาะกับแผน Professional รายปีเท่านั้น หากใช้แผนปัจจุบันต่อ จะไม่มีส่วนลดนี้รวมอยู่ด้วย",
"educationPricingConfirm.title": "แผนที่คุณเลือกไม่รองรับส่วนลดการศึกษา",
"educationPricingConfirm.cancel": "ยกเลิก",
"educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด",
"educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์",
"educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน",
"emailLabel": "อีเมลปัจจุบันของคุณ",
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",

View File

@ -16,10 +16,10 @@
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
"educationPricingConfirm.billingPeriod.monthly": "aylık",
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
"educationPricingConfirm.cancel": "Mevcut planı koru",
"educationPricingConfirm.continue": "Professional yıllık plana geç",
"educationPricingConfirm.description": "Eğitim indirimi yalnızca yıllık Professional planı için geçerlidir. Mevcut planınızı korursanız indirim dahil edilmez.",
"educationPricingConfirm.title": "Seçtiğiniz plan eğitim indirimini desteklemiyor",
"educationPricingConfirm.cancel": "İptal",
"educationPricingConfirm.continue": "İndirim olmadan devam et",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca yıllık Professional planı uygundur.",
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
"emailLabel": "Şu anki e-posta adresin",
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
"form.schoolName.title": "Okulunuzun Adı",

View File

@ -16,10 +16,10 @@
"currentSigned": "В даний момент ви підписані як",
"educationPricingConfirm.billingPeriod.monthly": "щомісячно",
"educationPricingConfirm.billingPeriod.yearly": "щорічно",
"educationPricingConfirm.cancel": "Залишити поточний план",
"educationPricingConfirm.continue": ерейти на Professional річний",
"educationPricingConfirm.description": "Освітня знижка застосовується лише до річного плану Professional. Якщо залишити поточний план, знижку не буде включено.",
"educationPricingConfirm.title": "Вибраний план не підтримує освітню знижку",
"educationPricingConfirm.cancel": "Скасувати",
"educationPricingConfirm.continue": родовжити без знижки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.",
"educationPricingConfirm.title": "Освітня знижка недоступна",
"emailLabel": "Ваш поточний електронний лист",
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
"form.schoolName.title": "Ваша назва школи",

View File

@ -16,10 +16,10 @@
"currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO",
"educationPricingConfirm.billingPeriod.monthly": "hàng tháng",
"educationPricingConfirm.billingPeriod.yearly": "hàng năm",
"educationPricingConfirm.cancel": "Giữ gói hiện tại",
"educationPricingConfirm.continue": "Chuyển sang Professional hằng năm",
"educationPricingConfirm.description": "Giảm giá giáo dục chỉ áp dụng cho gói Professional hng năm. Nếu giữ gói hiện tại, giảm giá sẽ không được áp dụng.",
"educationPricingConfirm.title": "Gói bạn chọn không hỗ trợ giảm giá giáo dục",
"educationPricingConfirm.cancel": "Hủy",
"educationPricingConfirm.continue": "Tiếp tục không có giảm giá",
"educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.",
"educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng",
"emailLabel": "Email hiện tại của bạn",
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
"form.schoolName.title": "Tên Trường Của Bạn",

View File

@ -16,10 +16,10 @@
"currentSigned": "您当前登录的账户是",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "保留当前计划",
"educationPricingConfirm.continue": "切换到 Professional 年付",
"educationPricingConfirm.description": "教育优惠仅适用于 Professional 年付计划。保留当前计划将不包含该优惠。",
"educationPricingConfirm.title": "你选择的计划不支持教育优惠",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用优惠继续",
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 年付计划符合条件。",
"educationPricingConfirm.title": "教育优惠不适用于该计划",
"emailLabel": "您当前的邮箱",
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
"form.schoolName.title": "您的学校名称",

View File

@ -16,10 +16,10 @@
"currentSigned": "當前以以下身份登入",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "保留目前方案",
"educationPricingConfirm.continue": "切換到 Professional 年付",
"educationPricingConfirm.description": "教育優惠僅適用於 Professional 年付方案。保留目前方案將不包含此優惠。",
"educationPricingConfirm.title": "你選擇的方案不支援教育優惠",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用優惠繼續",
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 年付方案符合資格。",
"educationPricingConfirm.title": "教育優惠不適用於此方案",
"emailLabel": "您當前的電子郵件",
"form.schoolName.placeholder": "請輸入您學校的正式全名",
"form.schoolName.title": "你的學校名稱",

View File

@ -339,7 +339,13 @@ export const uploadRemoteFileInfo = (url: string, isPublic?: boolean, silent?: b
export const sendEMailLoginCode = (email: string, language = 'en-US'): Promise<CommonResponse & { data: string }> =>
post<CommonResponse & { data: string }>('/email-code-login', { body: { email, language } })
export const emailLoginWithCode = (data: { email: string, code: string, token: string, language: string }): Promise<LoginResponse> =>
export const emailLoginWithCode = (data: {
email: string
code: string
token: string
language: string
timezone?: string
}): Promise<LoginResponse> =>
post<LoginResponse>('/email-code-login/validity', { body: data })
export const sendResetPasswordCode = (email: string, language = 'en-US'): Promise<CommonResponse & { data: string, message?: string, code?: string }> =>

View File

@ -178,7 +178,13 @@ export type MailRegisterResponse = { result: string, data: {} }
export const useMailRegister = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'mail-register'],
mutationFn: (body: { token: string, new_password: string, password_confirm: string }) => {
mutationFn: (body: {
token: string
new_password: string
password_confirm: string
language?: string
timezone?: string
}) => {
return post<MailRegisterResponse>('/email-register', { body })
},
})

View File

@ -5,3 +5,10 @@ type Item = {
name: string
}
export const timezones: Item[] = tz
export const getBrowserTimezone = () => {
if (typeof Intl === 'undefined')
return undefined
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined
}