mirror of
https://github.com/langgenius/dify.git
synced 2026-05-15 14:37:59 +08:00
Compare commits
9 Commits
fix/webapp
...
codex/init
| Author | SHA1 | Date | |
|---|---|---|---|
| 446d52f374 | |||
| eb7a274610 | |||
| 540a510d8e | |||
| 0836071203 | |||
| 454637060d | |||
| f086dbb9a8 | |||
| 926e3f8b29 | |||
| 356c1a21b9 | |||
| 6880c621ec |
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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": "",
|
||||
}
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
@ -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") == {}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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', {
|
||||
|
||||
86
web/app/signin/components/__tests__/social-auth.spec.tsx
Normal file
86
web/app/signin/components/__tests__/social-auth.spec.tsx
Normal 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=')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
139
web/app/signin/invite-settings/__tests__/page.spec.tsx
Normal file
139
web/app/signin/invite-settings/__tests__/page.spec.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
|
||||
85
web/app/signup/set-password/__tests__/page.spec.tsx
Normal file
85
web/app/signup/set-password/__tests__/page.spec.tsx
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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={
|
||||
|
||||
@ -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": "اسم مدرستك",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "نام مدرسه شما",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "आपके स्कूल का नाम",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "学校名",
|
||||
|
||||
@ -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": "당신의 학교 이름",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Название вашей школы",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ชื่อโรงเรียนของคุณ",
|
||||
|
||||
@ -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ı",
|
||||
|
||||
@ -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": "Ваша назва школи",
|
||||
|
||||
@ -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 hằng 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",
|
||||
|
||||
@ -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": "您的学校名称",
|
||||
|
||||
@ -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": "你的學校名稱",
|
||||
|
||||
@ -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 }> =>
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user