mirror of
https://github.com/langgenius/dify.git
synced 2026-06-22 19:21:13 +08:00
chore: improve invite member flow (#37479)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jingyi <jingyi.qi@dify.ai>
This commit is contained in:
parent
5873acc433
commit
26b0137c83
@ -1,6 +1,7 @@
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
@ -11,7 +12,8 @@ from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, timezone
|
||||
from models import AccountStatus
|
||||
from services.account_service import RegisterService
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
@ -25,18 +27,22 @@ class ActivatePayload(BaseModel):
|
||||
workspace_id: str | None = Field(default=None)
|
||||
email: EmailStr | None = Field(default=None)
|
||||
token: str
|
||||
name: str = Field(..., max_length=30)
|
||||
interface_language: str = Field(...)
|
||||
timezone: str = Field(...)
|
||||
name: str | None = Field(default=None, max_length=30)
|
||||
interface_language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
|
||||
@field_validator("interface_language")
|
||||
@classmethod
|
||||
def validate_lang(cls, value: str) -> str:
|
||||
def validate_lang(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return supported_language(value)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_tz(cls, value: str) -> str:
|
||||
def validate_tz(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return timezone(value)
|
||||
|
||||
|
||||
@ -48,6 +54,8 @@ class ActivationCheckData(BaseModel):
|
||||
workspace_name: str | None
|
||||
workspace_id: str | None
|
||||
email: str | None
|
||||
account_status: str | None = None
|
||||
requires_setup: bool | None = None
|
||||
|
||||
|
||||
class ActivationCheckResponse(BaseModel):
|
||||
@ -95,9 +103,20 @@ class ActivateCheckApi(Resource):
|
||||
workspace_name = tenant.name if tenant else None
|
||||
workspace_id = tenant.id if tenant else None
|
||||
invitee_email = data.get("email") if data else None
|
||||
account = invitation.get("account")
|
||||
account_status = account.status if account else None
|
||||
requires_setup = data.get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account_status == AccountStatus.PENDING
|
||||
return {
|
||||
"is_valid": invitation is not None,
|
||||
"data": {"workspace_name": workspace_name, "workspace_id": workspace_id, "email": invitee_email},
|
||||
"data": {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_id": workspace_id,
|
||||
"email": invitee_email,
|
||||
"account_status": account_status,
|
||||
"requires_setup": requires_setup,
|
||||
},
|
||||
}
|
||||
else:
|
||||
return {"is_valid": False}
|
||||
@ -126,15 +145,45 @@ class ActivateApi(Resource):
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
tenant = invitation["tenant"]
|
||||
raw_role = invitation["data"].get("role")
|
||||
try:
|
||||
role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL
|
||||
except ValueError:
|
||||
role = TenantAccountRole.NORMAL
|
||||
if not TenantAccountRole.is_non_owner_role(role):
|
||||
role = TenantAccountRole.NORMAL
|
||||
|
||||
membership_id = db.session.scalar(
|
||||
select(TenantAccountJoin.id).where(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.account_id == account.id,
|
||||
)
|
||||
)
|
||||
|
||||
requires_setup = invitation["data"].get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account.status == AccountStatus.PENDING
|
||||
|
||||
setup_fields: tuple[str, str, str] | None = None
|
||||
if requires_setup:
|
||||
if not args.name or not args.interface_language or not args.timezone:
|
||||
raise AlreadyActivateError()
|
||||
setup_fields = (args.name, args.interface_language, args.timezone)
|
||||
|
||||
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
|
||||
|
||||
account.name = args.name
|
||||
if membership_id is None:
|
||||
TenantService.create_tenant_member(tenant, account, str(role))
|
||||
|
||||
account.interface_language = args.interface_language
|
||||
account.timezone = args.timezone
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
if setup_fields:
|
||||
account.name = setup_fields[0]
|
||||
account.interface_language = setup_fields[1]
|
||||
account.timezone = setup_fields[2]
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@ -232,7 +232,11 @@ class MemberInviteEmailApi(Resource):
|
||||
)
|
||||
except AccountAlreadyInTenantError:
|
||||
invitation_results.append(
|
||||
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
|
||||
{
|
||||
"status": "already_member",
|
||||
"email": invitee_email,
|
||||
"message": "Account already in workspace.",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
|
||||
|
||||
@ -11253,9 +11253,9 @@ Default namespace
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | No |
|
||||
| interface_language | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| timezone | string | | Yes |
|
||||
| interface_language | string | | No |
|
||||
| name | string | | No |
|
||||
| timezone | string | | No |
|
||||
| token | string | | Yes |
|
||||
| workspace_id | string | | No |
|
||||
|
||||
@ -11263,7 +11263,9 @@ Default namespace
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| account_status | string | | No |
|
||||
| email | string | | Yes |
|
||||
| requires_setup | boolean | | No |
|
||||
| workspace_id | string | | Yes |
|
||||
| workspace_name | string | | Yes |
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from typing import Any, TypedDict, cast
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from sqlalchemy import Row, delete, func, select, update
|
||||
@ -18,6 +18,8 @@ class InvitationData(TypedDict):
|
||||
account_id: str
|
||||
email: str
|
||||
workspace_id: str
|
||||
role: NotRequired[str]
|
||||
requires_setup: NotRequired[bool]
|
||||
|
||||
|
||||
_invitation_adapter: TypeAdapter[InvitationData] = TypeAdapter(InvitationData)
|
||||
@ -1805,6 +1807,7 @@ class RegisterService:
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email)
|
||||
|
||||
requires_setup = False
|
||||
if not account:
|
||||
TenantService.check_member_permission(tenant, inviter, None, "add")
|
||||
name = normalized_email.split("@")[0]
|
||||
@ -1819,6 +1822,7 @@ class RegisterService:
|
||||
# Create new tenant member for invited tenant
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
requires_setup = True
|
||||
else:
|
||||
TenantService.check_member_permission(tenant, inviter, account, "add")
|
||||
ta = db.session.scalar(
|
||||
@ -1826,15 +1830,16 @@ class RegisterService:
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
requires_setup = account.status == AccountStatus.PENDING
|
||||
|
||||
if not ta:
|
||||
if not ta and account.status == AccountStatus.PENDING:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
|
||||
# Support resend invitation email when the account is pending status
|
||||
if account.status != AccountStatus.PENDING:
|
||||
if ta and account.status != AccountStatus.PENDING:
|
||||
raise AccountAlreadyInTenantError("Account already in tenant.")
|
||||
|
||||
token = cls.generate_invite_token(tenant, account)
|
||||
token = cls.generate_invite_token(tenant, account, role, requires_setup=requires_setup)
|
||||
language = account.interface_language or "en-US"
|
||||
|
||||
# send email
|
||||
@ -1849,12 +1854,16 @@ class RegisterService:
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def generate_invite_token(cls, tenant: Tenant, account: Account) -> str:
|
||||
def generate_invite_token(
|
||||
cls, tenant: Tenant, account: Account, role: str = "normal", *, requires_setup: bool = False
|
||||
) -> str:
|
||||
token = str(uuid.uuid4())
|
||||
invitation_data = {
|
||||
"account_id": account.id,
|
||||
"email": account.email,
|
||||
"workspace_id": tenant.id,
|
||||
"role": str(role),
|
||||
"requires_setup": requires_setup,
|
||||
}
|
||||
expiry_hours = dify_config.INVITE_EXPIRY_HOURS
|
||||
redis_client.setex(cls._get_invitation_token_key(token), expiry_hours * 60 * 60, json.dumps(invitation_data))
|
||||
@ -1889,16 +1898,7 @@ class RegisterService:
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
tenant_account = db.session.execute(
|
||||
select(Account, TenantAccountJoin.role)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
|
||||
).first()
|
||||
|
||||
if not tenant_account:
|
||||
return None
|
||||
|
||||
account = tenant_account[0]
|
||||
account = db.session.scalar(select(Account).where(Account.email == invitation_data["email"]).limit(1))
|
||||
if not account:
|
||||
return None
|
||||
|
||||
|
||||
@ -2755,7 +2755,7 @@ class TestRegisterService:
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test inviting an existing member who is not in the tenant yet.
|
||||
Test inviting an existing active account who is not in the tenant yet.
|
||||
"""
|
||||
fake = Faker()
|
||||
tenant_name = fake.company()
|
||||
@ -2791,20 +2791,20 @@ class TestRegisterService:
|
||||
# Mock the email task
|
||||
with patch("services.account_service.send_invite_member_mail_task") as mock_send_mail:
|
||||
mock_send_mail.delay.return_value = None
|
||||
with pytest.raises(AccountAlreadyInTenantError, match="Account already in tenant."):
|
||||
# Execute invitation
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=tenant,
|
||||
email=existing_member_email,
|
||||
language=language,
|
||||
role="admin",
|
||||
inviter=inviter,
|
||||
)
|
||||
|
||||
# Verify email task was not called
|
||||
mock_send_mail.delay.assert_not_called()
|
||||
token = RegisterService.invite_new_member(
|
||||
tenant=tenant,
|
||||
email=existing_member_email,
|
||||
language=language,
|
||||
role="admin",
|
||||
inviter=inviter,
|
||||
)
|
||||
|
||||
# Verify tenant member was created for existing account
|
||||
assert token is not None
|
||||
assert len(token) > 0
|
||||
mock_send_mail.delay.assert_called_once()
|
||||
|
||||
# Existing active accounts must accept the invite before becoming workspace members.
|
||||
from models.account import TenantAccountJoin
|
||||
|
||||
tenant_join = (
|
||||
@ -2812,8 +2812,13 @@ class TestRegisterService:
|
||||
.filter_by(tenant_id=tenant.id, account_id=existing_account.id)
|
||||
.first()
|
||||
)
|
||||
assert tenant_join is not None
|
||||
assert tenant_join.role == "admin"
|
||||
assert tenant_join is None
|
||||
|
||||
invitation = RegisterService.get_invitation_if_token_valid(None, None, token)
|
||||
assert invitation is not None
|
||||
assert invitation["account"].id == existing_account.id
|
||||
assert invitation["data"]["role"] == "admin"
|
||||
assert invitation["data"]["requires_setup"] is False
|
||||
|
||||
def test_invite_new_member_existing_member(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
|
||||
@ -66,6 +66,20 @@ class TestActivateCheckApi:
|
||||
assert response["data"]["workspace_id"] == "workspace-123"
|
||||
assert response["data"]["email"] == "invitee@example.com"
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
def test_check_valid_invitation_token_includes_account_status(self, mock_get_invitation, app, mock_invitation):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_invitation["account"] = mock_account
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
|
||||
with app.test_request_context("/activate/check?email=invitee@example.com&token=valid_token"):
|
||||
response = ActivateCheckApi().get()
|
||||
|
||||
assert response["is_valid"] is True
|
||||
assert response["data"]["account_status"] == AccountStatus.ACTIVE
|
||||
assert response["data"]["requires_setup"] is False
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
def test_check_invalid_invitation_token(self, mock_get_invitation, app: Flask):
|
||||
"""
|
||||
@ -177,6 +191,11 @@ class TestActivateApi:
|
||||
"account": mock_account,
|
||||
}
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_switch_tenant(self):
|
||||
with patch("controllers.console.auth.activate.TenantService.switch_tenant") as mock:
|
||||
yield mock
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
@ -224,7 +243,40 @@ class TestActivateApi:
|
||||
assert mock_account.status == AccountStatus.ACTIVE
|
||||
assert mock_account.initialized_at is not None
|
||||
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch("controllers.console.auth.activate.TenantService.create_tenant_member")
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
def test_activation_rejects_missing_setup_fields_before_consuming_invitation(
|
||||
self,
|
||||
mock_db,
|
||||
mock_revoke_token,
|
||||
mock_get_invitation,
|
||||
mock_create_tenant_member,
|
||||
app: Flask,
|
||||
mock_invitation,
|
||||
mock_switch_tenant,
|
||||
):
|
||||
mock_invitation["data"]["requires_setup"] = True
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/activate",
|
||||
method="POST",
|
||||
json={
|
||||
"workspace_id": "workspace-123",
|
||||
"email": "invitee@example.com",
|
||||
"token": "valid_token",
|
||||
},
|
||||
):
|
||||
with pytest.raises(AlreadyActivateError):
|
||||
ActivateApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_create_tenant_member.assert_not_called()
|
||||
mock_switch_tenant.assert_not_called()
|
||||
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
def test_activation_with_invalid_token(self, mock_get_invitation, app: Flask):
|
||||
@ -504,3 +556,75 @@ class TestActivateApi:
|
||||
assert response["result"] == "success"
|
||||
mock_get_invitation.assert_called_once_with("workspace-123", "Invitee@Example.com", "valid_token")
|
||||
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")
|
||||
|
||||
@patch("controllers.console.auth.activate.TenantService.create_tenant_member")
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
def test_activation_for_existing_active_account_creates_membership_on_acceptance(
|
||||
self,
|
||||
mock_db,
|
||||
mock_revoke_token,
|
||||
mock_get_invitation,
|
||||
mock_create_tenant_member,
|
||||
app: Flask,
|
||||
mock_invitation,
|
||||
mock_account,
|
||||
mock_switch_tenant,
|
||||
):
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_invitation["data"]["role"] = "admin"
|
||||
mock_invitation["data"]["requires_setup"] = False
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/activate",
|
||||
method="POST",
|
||||
json={
|
||||
"workspace_id": "workspace-123",
|
||||
"email": "invitee@example.com",
|
||||
"token": "valid_token",
|
||||
},
|
||||
):
|
||||
response = ActivateApi().post()
|
||||
|
||||
assert response["result"] == "success"
|
||||
mock_create_tenant_member.assert_called_once_with(mock_invitation["tenant"], mock_account, "admin")
|
||||
mock_switch_tenant.assert_called_once_with(mock_account, mock_invitation["tenant"].id)
|
||||
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")
|
||||
|
||||
@patch("controllers.console.auth.activate.TenantService.create_tenant_member")
|
||||
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
|
||||
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
|
||||
@patch("controllers.console.auth.activate.db")
|
||||
def test_activation_legacy_active_member_invitation_does_not_require_setup(
|
||||
self,
|
||||
mock_db,
|
||||
mock_revoke_token,
|
||||
mock_get_invitation,
|
||||
mock_create_tenant_member,
|
||||
app: Flask,
|
||||
mock_invitation,
|
||||
mock_account,
|
||||
mock_switch_tenant,
|
||||
):
|
||||
mock_account.status = AccountStatus.ACTIVE
|
||||
mock_get_invitation.return_value = mock_invitation
|
||||
mock_db.session.scalar.return_value = "membership-id"
|
||||
|
||||
with app.test_request_context(
|
||||
"/activate",
|
||||
method="POST",
|
||||
json={
|
||||
"workspace_id": "workspace-123",
|
||||
"email": "invitee@example.com",
|
||||
"token": "valid_token",
|
||||
},
|
||||
):
|
||||
response = ActivateApi().post()
|
||||
|
||||
assert response["result"] == "success"
|
||||
mock_create_tenant_member.assert_not_called()
|
||||
mock_switch_tenant.assert_called_once_with(mock_account, mock_invitation["tenant"].id)
|
||||
mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token")
|
||||
|
||||
@ -190,7 +190,9 @@ class TestMemberInviteEmailApi:
|
||||
):
|
||||
result, status = method(api, user)
|
||||
|
||||
assert result["invitation_results"][0]["status"] == "success"
|
||||
assert status == 201
|
||||
assert result["invitation_results"][0]["status"] == "already_member"
|
||||
assert result["invitation_results"][0]["message"] == "Account already in workspace."
|
||||
|
||||
def test_invite_invalid_role(self, app: Flask):
|
||||
api = MemberInviteEmailApi()
|
||||
|
||||
@ -1752,13 +1752,15 @@ class TestRegisterService:
|
||||
mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, None, "add")
|
||||
mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "normal")
|
||||
mock_switch_tenant.assert_called_once_with(mock_new_account, mock_tenant.id)
|
||||
mock_generate_token.assert_called_once_with(mock_tenant, mock_new_account)
|
||||
mock_generate_token.assert_called_once_with(
|
||||
mock_tenant, mock_new_account, "normal", requires_setup=True
|
||||
)
|
||||
mock_task_dependencies.delay.assert_called_once()
|
||||
|
||||
def test_invite_new_member_existing_account(
|
||||
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
|
||||
):
|
||||
"""Test inviting a new member who already has an account."""
|
||||
"""Test inviting a pending account that is not in the tenant yet."""
|
||||
# Setup test data
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
@ -1796,10 +1798,51 @@ class TestRegisterService:
|
||||
# Verify results
|
||||
assert result == "invite-token-123"
|
||||
mock_create_member.assert_called_once_with(mock_tenant, mock_existing_account, "normal")
|
||||
mock_generate_token.assert_called_once_with(mock_tenant, mock_existing_account)
|
||||
mock_generate_token.assert_called_once_with(
|
||||
mock_tenant, mock_existing_account, "normal", requires_setup=True
|
||||
)
|
||||
mock_task_dependencies.delay.assert_called_once()
|
||||
mock_lookup.assert_called_once_with("existing@example.com")
|
||||
|
||||
def test_invite_existing_active_account_requires_acceptance_before_joining(
|
||||
self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
|
||||
):
|
||||
"""Existing active accounts outside the tenant receive an invite without immediate membership."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_tenant.name = "Test Workspace"
|
||||
mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter")
|
||||
mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock(
|
||||
account_id="existing-user-456", email="existing@example.com", status="active"
|
||||
)
|
||||
|
||||
with patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup:
|
||||
mock_lookup.return_value = mock_existing_account
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
|
||||
with (
|
||||
patch("services.account_service.TenantService.check_member_permission") as mock_check_permission,
|
||||
patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
|
||||
patch("services.account_service.RegisterService.generate_invite_token") as mock_generate_token,
|
||||
):
|
||||
mock_generate_token.return_value = "invite-token-123"
|
||||
|
||||
result = RegisterService.invite_new_member(
|
||||
tenant=mock_tenant,
|
||||
email="existing@example.com",
|
||||
language="en-US",
|
||||
role="admin",
|
||||
inviter=mock_inviter,
|
||||
)
|
||||
|
||||
assert result == "invite-token-123"
|
||||
mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, mock_existing_account, "add")
|
||||
mock_create_member.assert_not_called()
|
||||
mock_generate_token.assert_called_once_with(
|
||||
mock_tenant, mock_existing_account, "admin", requires_setup=False
|
||||
)
|
||||
mock_task_dependencies.delay.assert_called_once()
|
||||
|
||||
def test_invite_new_member_already_in_tenant(self, mock_db_dependencies, mock_redis_dependencies):
|
||||
"""Test inviting a member who is already in the tenant."""
|
||||
# Setup test data
|
||||
@ -1864,7 +1907,7 @@ class TestRegisterService:
|
||||
mock_uuid.return_value = "test-uuid-123"
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.generate_invite_token(mock_tenant, mock_account)
|
||||
result = RegisterService.generate_invite_token(mock_tenant, mock_account, "admin", requires_setup=True)
|
||||
|
||||
# Verify results
|
||||
assert result == "test-uuid-123"
|
||||
@ -1877,6 +1920,8 @@ class TestRegisterService:
|
||||
assert stored_data["account_id"] == "user-123"
|
||||
assert stored_data["email"] == "test@example.com"
|
||||
assert stored_data["workspace_id"] == "tenant-456"
|
||||
assert stored_data["role"] == "admin"
|
||||
assert stored_data["requires_setup"] is True
|
||||
|
||||
def test_is_valid_invite_token_valid(self, mock_redis_dependencies):
|
||||
"""Test checking valid invite token."""
|
||||
@ -1943,9 +1988,8 @@ class TestRegisterService:
|
||||
}
|
||||
mock_get_invitation_by_token.return_value = invitation_data
|
||||
|
||||
# Mock scalar for tenant lookup, execute for account+role lookup
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
# Mock scalar for tenant lookup, then account lookup.
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, mock_account]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@ -2001,9 +2045,8 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
|
||||
# Mock scalar for tenant lookup, then account lookup.
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, None]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@ -2029,9 +2072,8 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
# Mock scalar for tenant lookup, then account lookup.
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, mock_account]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
|
||||
@ -7445,24 +7445,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/signin/invite-settings/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/signin/layout.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/signin/normal-form.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/signin/one-more-step.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -7695,7 +7682,7 @@
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 27
|
||||
"count": 26
|
||||
}
|
||||
},
|
||||
"web/service/datasets.ts": {
|
||||
|
||||
@ -6,9 +6,9 @@ export type ClientOptions = {
|
||||
|
||||
export type ActivatePayload = {
|
||||
email?: string | null
|
||||
interface_language: string
|
||||
name: string
|
||||
timezone: string
|
||||
interface_language?: string | null
|
||||
name?: string | null
|
||||
timezone?: string | null
|
||||
token: string
|
||||
workspace_id?: string | null
|
||||
}
|
||||
@ -23,7 +23,9 @@ export type ActivationCheckResponse = {
|
||||
}
|
||||
|
||||
export type ActivationCheckData = {
|
||||
account_status?: string | null
|
||||
email: string | null
|
||||
requires_setup?: boolean | null
|
||||
workspace_id: string | null
|
||||
workspace_name: string | null
|
||||
}
|
||||
|
||||
@ -7,9 +7,9 @@ import * as z from 'zod'
|
||||
*/
|
||||
export const zActivatePayload = z.object({
|
||||
email: z.string().nullish(),
|
||||
interface_language: z.string(),
|
||||
name: z.string().max(30),
|
||||
timezone: z.string(),
|
||||
interface_language: z.string().nullish(),
|
||||
name: z.string().max(30).nullish(),
|
||||
timezone: z.string().nullish(),
|
||||
token: z.string(),
|
||||
workspace_id: z.string().nullish(),
|
||||
})
|
||||
@ -25,7 +25,9 @@ export const zActivationResponse = z.object({
|
||||
* ActivationCheckData
|
||||
*/
|
||||
export const zActivationCheckData = z.object({
|
||||
account_status: z.string().nullish(),
|
||||
email: z.string().nullable(),
|
||||
requires_setup: z.boolean().nullish(),
|
||||
workspace_id: z.string().nullable(),
|
||||
workspace_name: z.string().nullable(),
|
||||
})
|
||||
|
||||
@ -14,6 +14,7 @@ describe('InvitedModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const results: InvitationResult[] = [
|
||||
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
|
||||
{ email: 'member@example.com', status: 'already_member', message: 'Account already in workspace.' },
|
||||
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
|
||||
]
|
||||
|
||||
@ -28,6 +29,8 @@ describe('InvitedModal', () => {
|
||||
expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument()
|
||||
expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
|
||||
expect(screen.getByText(/members\.alreadyInTeam$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('member@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -53,6 +56,19 @@ describe('InvitedModal', () => {
|
||||
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show already-member message without invitation copy when every email is already a member', () => {
|
||||
const alreadyMembers: InvitationResult[] = [
|
||||
{ email: 'member@example.com', status: 'already_member' },
|
||||
]
|
||||
|
||||
render(<InvitedModal invitationResults={alreadyMembers} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByText(/members\.noNewInvitationsSent/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/members\.alreadyInTeamTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('member@example.com')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide both sections when results are empty', () => {
|
||||
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
|
||||
|
||||
@ -85,4 +101,16 @@ describe('InvitedModal (non-CE edition)', () => {
|
||||
// CE-only content should not be shown
|
||||
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show already-member details when IS_CE_EDITION is false', () => {
|
||||
const results: InvitationResult[] = [
|
||||
{ email: 'member@example.com', status: 'already_member' },
|
||||
]
|
||||
|
||||
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByText(/members\.noNewInvitationsSent/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/members\.alreadyInTeam$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('member@example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,12 +2,12 @@ import type { InvitationResult } from '@/models/common'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import InvitationLink from './invitation-link'
|
||||
|
||||
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
|
||||
type AlreadyMemberInvitationResult = Extract<InvitationResult, { status: 'already_member' }>
|
||||
type FailedInvitationResult = Extract<InvitationResult, { status: 'failed' }>
|
||||
|
||||
type IInvitedModalProps = {
|
||||
@ -20,8 +20,17 @@ const InvitedModal = ({
|
||||
}: IInvitedModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const successInvitationResults = useMemo<SuccessInvitationResult[]>(() => invitationResults?.filter(item => item.status === 'success') as SuccessInvitationResult[], [invitationResults])
|
||||
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
|
||||
const successInvitationResults = invitationResults.filter(
|
||||
(item): item is SuccessInvitationResult => item.status === 'success',
|
||||
)
|
||||
const alreadyMemberInvitationResults = invitationResults.filter(
|
||||
(item): item is AlreadyMemberInvitationResult => item.status === 'already_member',
|
||||
)
|
||||
const failedInvitationResults = invitationResults.filter(
|
||||
(item): item is FailedInvitationResult => item.status === 'failed',
|
||||
)
|
||||
const onlyAlreadyMembers = alreadyMemberInvitationResults.length > 0 && successInvitationResults.length === 0 && failedInvitationResults.length === 0
|
||||
const description = t(onlyAlreadyMembers ? 'members.alreadyInTeamTip' : 'members.invitationSentTip', { ns: 'common' })
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -46,16 +55,20 @@ const InvitedModal = ({
|
||||
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
|
||||
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">
|
||||
{t(onlyAlreadyMembers ? 'members.noNewInvitationsSent' : 'members.invitationSent', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
{!IS_CE_EDITION && (
|
||||
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
|
||||
<div className="mb-5 text-sm text-text-tertiary">{description}</div>
|
||||
)}
|
||||
{IS_CE_EDITION && (
|
||||
{(IS_CE_EDITION || !!alreadyMemberInvitationResults.length) && (
|
||||
<>
|
||||
<div className="mb-5 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
|
||||
{IS_CE_EDITION && (
|
||||
<div className="mb-5 text-sm text-text-tertiary">{description}</div>
|
||||
)}
|
||||
<div className="mb-9 flex flex-col gap-2">
|
||||
{
|
||||
!!successInvitationResults.length
|
||||
IS_CE_EDITION && !!successInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
|
||||
@ -65,7 +78,30 @@ const InvitedModal = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!!failedInvitationResults.length
|
||||
!!alreadyMemberInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.alreadyInTeam', { ns: 'common' })}</div>
|
||||
{!onlyAlreadyMembers && (
|
||||
<div className="text-sm text-text-tertiary">{t('members.alreadyInTeamTip', { ns: 'common' })}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-between gap-y-1">
|
||||
{
|
||||
alreadyMemberInvitationResults.map(item => (
|
||||
<div
|
||||
key={item.email}
|
||||
className="flex justify-center rounded-md border border-components-panel-border bg-background-section-burn px-1 text-sm text-text-secondary"
|
||||
>
|
||||
{item.email}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
IS_CE_EDITION && !!failedInvitationResults.length
|
||||
&& (
|
||||
<>
|
||||
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
|
||||
|
||||
113
web/app/signin/__tests__/normal-form.spec.tsx
Normal file
113
web/app/signin/__tests__/normal-form.spec.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import NormalForm from '../normal-form'
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query')
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(),
|
||||
useSuspenseQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/account-profile/client', () => ({
|
||||
isLegacyBase401: vi.fn(() => false),
|
||||
userProfileQueryOptions: vi.fn(() => ({
|
||||
queryKey: ['account', 'profile'],
|
||||
queryFn: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: vi.fn(() => ({
|
||||
queryKey: ['system-features'],
|
||||
queryFn: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/service/common')>('@/service/common')
|
||||
return {
|
||||
...actual,
|
||||
invitationCheck: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseQuery = vi.mocked(useQuery)
|
||||
const mockUseSuspenseQuery = vi.mocked(useSuspenseQuery)
|
||||
const mockUseRouter = useRouter as unknown as ReturnType<typeof vi.fn>
|
||||
const mockUseSearchParams = useSearchParams as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const loggedInQueryResult = {
|
||||
isPending: false,
|
||||
data: {
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
const invitationQueryResult = {
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('NormalForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace })
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams('invite_token=invite-token'))
|
||||
mockUseSuspenseQuery.mockReturnValue({
|
||||
data: {
|
||||
enable_social_oauth_login: false,
|
||||
sso_enforced_for_signin: false,
|
||||
enable_email_code_login: false,
|
||||
enable_email_password_login: true,
|
||||
is_email_setup: true,
|
||||
is_allow_register: false,
|
||||
license: {
|
||||
status: 'none',
|
||||
},
|
||||
branding: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof useSuspenseQuery>)
|
||||
})
|
||||
|
||||
describe('Invite Redirects', () => {
|
||||
it('should send logged-in invite visitors to the invite confirmation page', async () => {
|
||||
mockUseQuery
|
||||
.mockReturnValueOnce(loggedInQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
.mockReturnValueOnce(invitationQueryResult as unknown as ReturnType<typeof useQuery>)
|
||||
|
||||
render(<NormalForm />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/signin/invite-settings?invite_token=invite-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -86,6 +86,7 @@ describe('InviteSettingsPage', () => {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
requires_setup: true,
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
@ -138,5 +139,99 @@ describe('InviteSettingsPage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should only submit the token when an active account accepts an invitation', async () => {
|
||||
mockUseInvitationCheck.mockReturnValue({
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
account_status: 'active',
|
||||
requires_setup: false,
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.queryByLabelText('login.name')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should only submit the token when an active account check omits setup state', async () => {
|
||||
mockUseInvitationCheck.mockReturnValue({
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
account_status: 'active',
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
|
||||
render(<InviteSettingsPage />)
|
||||
|
||||
expect(screen.queryByLabelText('login.name')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.join Acme' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateMember).toHaveBeenCalledWith({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit setup fields when the invitation requires account setup', async () => {
|
||||
mockUseInvitationCheck.mockReturnValue({
|
||||
data: {
|
||||
is_valid: true,
|
||||
data: {
|
||||
workspace_name: 'Acme',
|
||||
workspace_id: 'workspace-id',
|
||||
email: 'invitee@example.com',
|
||||
account_status: 'active',
|
||||
requires_setup: true,
|
||||
},
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useInvitationCheck>)
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiAccountCircleLine } from '@remixicon/react'
|
||||
@ -8,7 +9,6 @@ import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useState } from 'react'
|
||||
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 { useLocale } from '@/context/i18n'
|
||||
@ -85,25 +85,32 @@ export default function InviteSettingsPage() {
|
||||
},
|
||||
}
|
||||
const { data: checkRes, refetch: recheck } = useInvitationCheck(checkParams.params, !!token)
|
||||
const requiresAccountSetup = checkRes?.data?.requires_setup ?? checkRes?.data?.account_status === 'pending'
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
try {
|
||||
if (!name) {
|
||||
if (requiresAccountSetup && !name) {
|
||||
toast.error(t('enterYourName', { ns: 'login' }))
|
||||
return
|
||||
}
|
||||
const body = requiresAccountSetup
|
||||
? {
|
||||
token,
|
||||
name,
|
||||
interface_language: language,
|
||||
timezone,
|
||||
}
|
||||
: {
|
||||
token,
|
||||
}
|
||||
const res = await activateMember({
|
||||
url: '/activate',
|
||||
body: {
|
||||
token,
|
||||
name,
|
||||
interface_language: language,
|
||||
timezone,
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (res.result === 'success') {
|
||||
// Tokens are now stored in cookies by the backend
|
||||
await setLocaleOnClient(language!, false)
|
||||
if (requiresAccountSetup)
|
||||
await setLocaleOnClient(language!, false)
|
||||
await queryClient.resetQueries({ queryKey: consoleQuery.account.profile.get.key() })
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
router.replace(redirectUrl || '/')
|
||||
@ -112,7 +119,7 @@ export default function InviteSettingsPage() {
|
||||
catch {
|
||||
recheck()
|
||||
}
|
||||
}, [language, name, queryClient, recheck, searchParams, timezone, token, router, t])
|
||||
}, [language, name, queryClient, recheck, requiresAccountSetup, searchParams, timezone, token, router, t])
|
||||
|
||||
if (!checkRes)
|
||||
return <Loading />
|
||||
@ -138,77 +145,84 @@ export default function InviteSettingsPage() {
|
||||
<RiAccountCircleLine className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h2 className="title-4xl-semi-bold text-text-primary">{t('setYourAccount', { ns: 'login' })}</h2>
|
||||
<h2 className="title-4xl-semi-bold text-text-primary">
|
||||
{requiresAccountSetup
|
||||
? t('setYourAccount', { ns: 'login' })
|
||||
: `${t('join', { ns: 'login' })}${checkRes?.data?.workspace_name}`}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={noop}>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="name" className="my-2 system-md-semibold text-text-secondary">
|
||||
{t('name', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleActivate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary">
|
||||
{t('interfaceLanguage', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger id="interface_language" size="large">
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* timezone */}
|
||||
<div className="mb-5">
|
||||
<label htmlFor="timezone" className="system-md-semibold text-text-secondary">
|
||||
{t('timezone', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={selectedTimezone?.value ?? null}
|
||||
onValueChange={handleTimezoneChange}
|
||||
>
|
||||
<SelectTrigger id="timezone" size="large">
|
||||
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{requiresAccountSetup && (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="name" className="my-2 system-md-semibold text-text-secondary">
|
||||
{t('name', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleActivate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="interface_language" className="my-2 system-md-semibold text-text-secondary">
|
||||
{t('interfaceLanguage', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger id="interface_language" size="large">
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="timezone" className="system-md-semibold text-text-secondary">
|
||||
{t('timezone', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
value={selectedTimezone?.value ?? null}
|
||||
onValueChange={handleTimezoneChange}
|
||||
>
|
||||
<SelectTrigger id="timezone" size="large">
|
||||
{selectedTimezone?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEZONE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@ -74,9 +74,14 @@ function NormalForm() {
|
||||
if (!isLoggedIn)
|
||||
return
|
||||
|
||||
if (isInviteLink) {
|
||||
router.replace(`/signin/invite-settings?${searchParams.toString()}`)
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
router.replace(redirectUrl || '/')
|
||||
}, [isLoggedIn, router, searchParams])
|
||||
}, [isInviteLink, isLoggedIn, router, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (message)
|
||||
@ -197,9 +202,13 @@ function NormalForm() {
|
||||
<>
|
||||
<MailAndCodeAuth isInvite={isInviteLink} />
|
||||
{hasEmailPasswordLogin && (
|
||||
<div className="cursor-pointer py-1 text-center" onClick={() => { setSelectedAuthType('password') }}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer py-1 text-center"
|
||||
onClick={() => { setSelectedAuthType('password') }}
|
||||
>
|
||||
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('usePassword', { ns: 'login' })}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -207,9 +216,13 @@ function NormalForm() {
|
||||
<>
|
||||
<MailAndPasswordAuth isInvite={isInviteLink} isEmailSetup={systemFeatures.is_email_setup} />
|
||||
{hasEmailCodeLogin && (
|
||||
<div className="cursor-pointer py-1 text-center" onClick={() => { setSelectedAuthType('code') }}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer py-1 text-center"
|
||||
onClick={() => { setSelectedAuthType('code') }}
|
||||
>
|
||||
<span className="system-xs-medium text-components-button-secondary-accent-text">{t('useVerificationCode', { ns: 'login' })}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "اتصل بخوادم MCP وأدرها لمنح تطبيقاتك إمكانية الوصول إلى الأدوات والخدمات الخارجية.",
|
||||
"members.admin": "المسؤول",
|
||||
"members.adminTip": "يمكنه بناء التطبيقات وإدارة إعدادات الفريق",
|
||||
"members.alreadyInTeam": "موجود بالفعل في الفريق",
|
||||
"members.alreadyInTeamTip": "هؤلاء المستخدمون لديهم بالفعل إمكانية الوصول إلى مساحة العمل هذه.",
|
||||
"members.builder": "باني",
|
||||
"members.builderTip": "يمكنه بناء وتعديل تطبيقاته الخاصة",
|
||||
"members.datasetOperator": "مسؤول المعرفة",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "تمت الدعوة كمستخدم {{role}}",
|
||||
"members.lastActive": "آخر نشاط",
|
||||
"members.name": "الاسم",
|
||||
"members.noNewInvitationsSent": "لم يتم إرسال دعوات جديدة",
|
||||
"members.normal": "عادي",
|
||||
"members.normalTip": "يمكنه استخدام التطبيقات فقط، ولا يمكنه بناء التطبيقات",
|
||||
"members.ok": "موافق",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Verbinde und verwalte MCP-Server, damit deine Apps auf externe Tools und Dienste zugreifen können.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Kann Apps erstellen & Team-Einstellungen verwalten",
|
||||
"members.alreadyInTeam": "Bereits im Team",
|
||||
"members.alreadyInTeamTip": "Diese Benutzer haben bereits Zugriff auf diesen Arbeitsbereich.",
|
||||
"members.builder": "Bauherr",
|
||||
"members.builderTip": "Kann eigene Apps erstellen und bearbeiten",
|
||||
"members.datasetOperator": "Wissensadministrator",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Eingeladen als {{role}}-Benutzer",
|
||||
"members.lastActive": "ZULETZT AKTIV",
|
||||
"members.name": "NAME",
|
||||
"members.noNewInvitationsSent": "Keine neuen Einladungen gesendet",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Kann nur Apps verwenden, kann keine Apps erstellen",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Connect and manage MCP servers to give your apps access to external tools and services.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Can build apps & manage team settings",
|
||||
"members.alreadyInTeam": "Already in team",
|
||||
"members.alreadyInTeamTip": "These users already have access to this workspace.",
|
||||
"members.builder": "Builder",
|
||||
"members.builderTip": "Can build & edit own apps",
|
||||
"members.datasetOperator": "Knowledge Admin",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invited as {{role}} user",
|
||||
"members.lastActive": "LAST ACTIVE",
|
||||
"members.name": "NAME",
|
||||
"members.noNewInvitationsSent": "No new invitations sent",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Only can use apps, can not build apps",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Conecta y gestiona servidores MCP para dar a tus apps acceso a herramientas y servicios externos.",
|
||||
"members.admin": "Administrador",
|
||||
"members.adminTip": "Puede crear aplicaciones y administrar configuraciones del equipo",
|
||||
"members.alreadyInTeam": "Ya está en el equipo",
|
||||
"members.alreadyInTeamTip": "Estos usuarios ya tienen acceso a este espacio de trabajo.",
|
||||
"members.builder": "Constructor",
|
||||
"members.builderTip": "Puede crear y editar sus propias aplicaciones",
|
||||
"members.datasetOperator": "Administrador de Conocimiento",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invitado como usuario {{role}}",
|
||||
"members.lastActive": "ÚLTIMA ACTIVIDAD",
|
||||
"members.name": "NOMBRE",
|
||||
"members.noNewInvitationsSent": "No se han enviado nuevas invitaciones",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Solo puede usar aplicaciones, no puede crear aplicaciones",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "سرورهای MCP را وصل و مدیریت کنید تا برنامههای شما به ابزارها و سرویسهای خارجی دسترسی داشته باشند.",
|
||||
"members.admin": "مدیر",
|
||||
"members.adminTip": "میتواند برنامهها را بسازد و تنظیمات تیم را مدیریت کند",
|
||||
"members.alreadyInTeam": "در حال حاضر در تیم است",
|
||||
"members.alreadyInTeamTip": "این کاربران از قبل به این فضای کاری دسترسی دارند.",
|
||||
"members.builder": "سازنده",
|
||||
"members.builderTip": "میتواند برنامههای خود را بسازد و ویرایش کند",
|
||||
"members.datasetOperator": "مدیر دانش",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "به عنوان کاربر {{role}} دعوت شده",
|
||||
"members.lastActive": "آخرین فعالیت",
|
||||
"members.name": "نام",
|
||||
"members.noNewInvitationsSent": "هیچ دعوتنامه جدیدی ارسال نشد",
|
||||
"members.normal": "عادی",
|
||||
"members.normalTip": "فقط میتواند از برنامهها استفاده کند، نمیتواند برنامه بسازد",
|
||||
"members.ok": "تایید",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Connectez et gérez des serveurs MCP pour donner à vos apps accès à des outils et services externes.",
|
||||
"members.admin": "Administrateur",
|
||||
"members.adminTip": "Peut construire des applications & gérer les paramètres de l'équipe",
|
||||
"members.alreadyInTeam": "Déjà dans l’équipe",
|
||||
"members.alreadyInTeamTip": "Ces utilisateurs ont déjà accès à cet espace de travail.",
|
||||
"members.builder": "Constructeur",
|
||||
"members.builderTip": "Peut créer et modifier ses propres applications",
|
||||
"members.datasetOperator": "Administrateur des connaissances",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invité en tant qu'utilisateur {{role}}",
|
||||
"members.lastActive": "DERNIÈRE ACTIVITÉ",
|
||||
"members.name": "NOM",
|
||||
"members.noNewInvitationsSent": "Aucune nouvelle invitation envoyée",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Peut seulement utiliser des applications, ne peut pas construire des applications",
|
||||
"members.ok": "D'accord",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "MCP सर्वर कनेक्ट और प्रबंधित करें ताकि आपके ऐप्स बाहरी टूल और सेवाओं तक पहुँच सकें।",
|
||||
"members.admin": "प्रशासक",
|
||||
"members.adminTip": "ऐप्स बना सकते हैं और टीम सेटिंग्स का प्रबंधन कर सकते हैं",
|
||||
"members.alreadyInTeam": "पहले से टीम में हैं",
|
||||
"members.alreadyInTeamTip": "इन उपयोगकर्ताओं के पास पहले से इस वर्कस्पेस की पहुंच है।",
|
||||
"members.builder": "निर्माता",
|
||||
"members.builderTip": "अपने स्वयं के ऐप्स बना और संपादित कर सकते हैं",
|
||||
"members.datasetOperator": "ज्ञान व्यवस्थापक",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "{{role}} उपयोगकर्ता के रूप में आमंत्रित किया गया",
|
||||
"members.lastActive": "अंतिम सक्रियता",
|
||||
"members.name": "नाम",
|
||||
"members.noNewInvitationsSent": "कोई नया आमंत्रण नहीं भेजा गया",
|
||||
"members.normal": "सामान्य",
|
||||
"members.normalTip": "केवल ऐप्स का उपयोग कर सकते हैं, ऐप्स नहीं बना सकते",
|
||||
"members.ok": "ठीक है",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Hubungkan dan kelola server MCP agar aplikasi Anda dapat mengakses alat dan layanan eksternal.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Dapat membangun aplikasi & mengelola pengaturan tim",
|
||||
"members.alreadyInTeam": "Sudah ada di tim",
|
||||
"members.alreadyInTeamTip": "Pengguna ini sudah memiliki akses ke ruang kerja ini.",
|
||||
"members.builder": "Pembangun",
|
||||
"members.builderTip": "Dapat membangun & mengedit aplikasi sendiri",
|
||||
"members.datasetOperator": "Admin Pengetahuan",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Diundang sebagai pengguna {{role}}",
|
||||
"members.lastActive": "TERAKHIR AKTIF",
|
||||
"members.name": "NAMA",
|
||||
"members.noNewInvitationsSent": "Tidak ada undangan baru yang dikirim",
|
||||
"members.normal": "Biasa",
|
||||
"members.normalTip": "Hanya dapat menggunakan aplikasi, tidak dapat membuat aplikasi",
|
||||
"members.ok": "OKE",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Connetti e gestisci server MCP per dare alle tue app accesso a strumenti e servizi esterni.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Può creare app e gestire le impostazioni del team",
|
||||
"members.alreadyInTeam": "Già nel team",
|
||||
"members.alreadyInTeamTip": "Questi utenti hanno già accesso a questo spazio di lavoro.",
|
||||
"members.builder": "Builder",
|
||||
"members.builderTip": "Può creare e modificare le proprie app",
|
||||
"members.datasetOperator": "Admin della Conoscenza",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invitato come utente {{role}}",
|
||||
"members.lastActive": "ULTIMA ATTIVITÀ",
|
||||
"members.name": "NOME",
|
||||
"members.noNewInvitationsSent": "Nessun nuovo invito inviato",
|
||||
"members.normal": "Normale",
|
||||
"members.normalTip": "Può solo usare le app, non può crearle",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "MCP サーバーを接続・管理して、アプリから外部ツールやサービスにアクセスできるようにします。",
|
||||
"members.admin": "管理者",
|
||||
"members.adminTip": "アプリの構築およびチーム設定の管理ができます",
|
||||
"members.alreadyInTeam": "すでにチームに参加済み",
|
||||
"members.alreadyInTeamTip": "これらのユーザーはすでにこのワークスペースにアクセスできます。",
|
||||
"members.builder": "ビルダー",
|
||||
"members.builderTip": "独自のアプリを作成・編集できる",
|
||||
"members.datasetOperator": "ナレッジ管理員",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "{{role}}ユーザーとして招待されました",
|
||||
"members.lastActive": "最終アクティブ",
|
||||
"members.name": "名前",
|
||||
"members.noNewInvitationsSent": "新しい招待は送信されませんでした",
|
||||
"members.normal": "通常",
|
||||
"members.normalTip": "アプリの使用のみが可能で、アプリの構築はできません",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "MCP 서버를 연결하고 관리하여 앱이 외부 도구와 서비스에 접근할 수 있도록 하세요.",
|
||||
"members.admin": "관리자",
|
||||
"members.adminTip": "앱 빌드 및 팀 설정 관리 가능",
|
||||
"members.alreadyInTeam": "이미 팀에 있습니다",
|
||||
"members.alreadyInTeamTip": "이 사용자들은 이미 이 작업 공간에 액세스할 수 있습니다.",
|
||||
"members.builder": "빌더",
|
||||
"members.builderTip": "자신의 앱을 구축 및 편집할 수 있습니다.",
|
||||
"members.datasetOperator": "지식 관리자",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "{{role}} 사용자로 초대되었습니다",
|
||||
"members.lastActive": "최근 활동",
|
||||
"members.name": "이름",
|
||||
"members.noNewInvitationsSent": "새 초대가 전송되지 않았습니다",
|
||||
"members.normal": "일반",
|
||||
"members.normalTip": "앱 사용만 가능하고 앱 빌드는 불가능",
|
||||
"members.ok": "확인",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Verbind en beheer MCP-servers zodat je apps toegang krijgen tot externe tools en services.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Can build apps & manage team settings",
|
||||
"members.alreadyInTeam": "Al in het team",
|
||||
"members.alreadyInTeamTip": "Deze gebruikers hebben al toegang tot deze werkruimte.",
|
||||
"members.builder": "Builder",
|
||||
"members.builderTip": "Can build & edit own apps",
|
||||
"members.datasetOperator": "Knowledge Admin",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invited as {{role}} user",
|
||||
"members.lastActive": "LAST ACTIVE",
|
||||
"members.name": "NAME",
|
||||
"members.noNewInvitationsSent": "Geen nieuwe uitnodigingen verzonden",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Only can use apps, can not build apps",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Łącz i zarządzaj serwerami MCP, aby aplikacje mogły korzystać z zewnętrznych narzędzi i usług.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Może tworzyć aplikacje i zarządzać ustawieniami zespołu",
|
||||
"members.alreadyInTeam": "Już w zespole",
|
||||
"members.alreadyInTeamTip": "Ci użytkownicy mają już dostęp do tego obszaru roboczego.",
|
||||
"members.builder": "Budowniczy",
|
||||
"members.builderTip": "Może tworzyć i edytować własne aplikacje",
|
||||
"members.datasetOperator": "Wiedza Admin",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Zaproszony jako użytkownik typu {{role}}",
|
||||
"members.lastActive": "OSTATNIA AKTYWNOŚĆ",
|
||||
"members.name": "NAZWA",
|
||||
"members.noNewInvitationsSent": "Nie wysłano nowych zaproszeń",
|
||||
"members.normal": "Normalny",
|
||||
"members.normalTip": "Może tylko korzystać z aplikacji, nie może tworzyć aplikacji",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Conecte e gerencie servidores MCP para dar aos seus apps acesso a ferramentas e serviços externos.",
|
||||
"members.admin": "Admin",
|
||||
"members.adminTip": "Pode criar aplicativos e gerenciar configurações da equipe",
|
||||
"members.alreadyInTeam": "Já está na equipe",
|
||||
"members.alreadyInTeamTip": "Estes usuários já têm acesso a este espaço de trabalho.",
|
||||
"members.builder": "Construtor",
|
||||
"members.builderTip": "Pode criar e editar seus próprios aplicativos",
|
||||
"members.datasetOperator": "Administrador de conhecimento",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Convidado como usuário {{role}}",
|
||||
"members.lastActive": "ÚLTIMA ATIVIDADE",
|
||||
"members.name": "NOME",
|
||||
"members.noNewInvitationsSent": "Nenhum novo convite enviado",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Só pode usar aplicativos, não pode criar aplicativos",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Conectează și administrează servere MCP pentru a oferi aplicațiilor acces la instrumente și servicii externe.",
|
||||
"members.admin": "Administrator",
|
||||
"members.adminTip": "Poate construi aplicații și gestiona setările echipei",
|
||||
"members.alreadyInTeam": "Deja în echipă",
|
||||
"members.alreadyInTeamTip": "Acești utilizatori au deja acces la acest spațiu de lucru.",
|
||||
"members.builder": "Constructor",
|
||||
"members.builderTip": "Poate construi și edita propriile aplicații",
|
||||
"members.datasetOperator": "Administrator de cunoștințe",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Invitat ca utilizator {{role}}",
|
||||
"members.lastActive": "ULTIMA ACTIVITATE",
|
||||
"members.name": "NUME",
|
||||
"members.noNewInvitationsSent": "Nu au fost trimise invitații noi",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Poate doar utiliza aplicații, nu poate construi aplicații",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Подключайте и управляйте MCP-серверами, чтобы приложения могли обращаться к внешним инструментам и сервисам.",
|
||||
"members.admin": "Администратор",
|
||||
"members.adminTip": "Может создавать приложения и управлять настройками команды",
|
||||
"members.alreadyInTeam": "Уже в команде",
|
||||
"members.alreadyInTeamTip": "Эти пользователи уже имеют доступ к этому рабочему пространству.",
|
||||
"members.builder": "Разработчик",
|
||||
"members.builderTip": "Может создавать и редактировать собственные приложения",
|
||||
"members.datasetOperator": "Администратор знаний",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Приглашен как пользователь с ролью {{role}}",
|
||||
"members.lastActive": "ПОСЛЕДНЯЯ АКТИВНОСТЬ",
|
||||
"members.name": "ИМЯ",
|
||||
"members.noNewInvitationsSent": "Новые приглашения не отправлены",
|
||||
"members.normal": "Обычный",
|
||||
"members.normalTip": "Может только использовать приложения, не может создавать приложения",
|
||||
"members.ok": "ОК",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Povežite in upravljajte strežnike MCP, da aplikacijam omogočite dostop do zunanjih orodij in storitev.",
|
||||
"members.admin": "Administrator",
|
||||
"members.adminTip": "Lahko ustvarja aplikacije in upravlja nastavitve ekipe",
|
||||
"members.alreadyInTeam": "Že v ekipi",
|
||||
"members.alreadyInTeamTip": "Ti uporabniki že imajo dostop do tega delovnega prostora.",
|
||||
"members.builder": "Graditelj",
|
||||
"members.builderTip": "Lahko ustvarja in ureja lastne aplikacije",
|
||||
"members.datasetOperator": "Skrbnik znanja",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Povabljen kot uporabnik {{role}}",
|
||||
"members.lastActive": "NAZADNJE AKTIVEN",
|
||||
"members.name": "IME",
|
||||
"members.noNewInvitationsSent": "Nova povabila niso bila poslana",
|
||||
"members.normal": "Običajni uporabnik",
|
||||
"members.normalTip": "Lahko uporablja samo aplikacije, ne more ustvarjati aplikacij",
|
||||
"members.ok": "V redu",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "เชื่อมต่อและจัดการเซิร์ฟเวอร์ MCP เพื่อให้แอปของคุณเข้าถึงเครื่องมือและบริการภายนอกได้",
|
||||
"members.admin": "ผู้ดูแลระบบ",
|
||||
"members.adminTip": "สามารถสร้างแอพและจัดการการตั้งค่าทีมได้",
|
||||
"members.alreadyInTeam": "อยู่ในทีมแล้ว",
|
||||
"members.alreadyInTeamTip": "ผู้ใช้เหล่านี้มีสิทธิ์เข้าถึงพื้นที่ทำงานนี้อยู่แล้ว",
|
||||
"members.builder": "ผู้สร้าง",
|
||||
"members.builderTip": "สามารถสร้างและแก้ไขแอปของตัวเองได้",
|
||||
"members.datasetOperator": "ผู้ดูแลระบบความรู้",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "ได้รับเชิญให้เป็นผู้ใช้ {{role}}",
|
||||
"members.lastActive": "ใช้งานล่าสุด",
|
||||
"members.name": "ชื่อ",
|
||||
"members.noNewInvitationsSent": "ไม่มีการส่งคำเชิญใหม่",
|
||||
"members.normal": "ปกติ",
|
||||
"members.normalTip": "ใช้ได้เฉพาะแอพ สร้างแอพไม่ได้",
|
||||
"members.ok": "ตกลง, ได้",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Uygulamalarınıza harici araç ve servislere erişim vermek için MCP sunucularını bağlayın ve yönetin.",
|
||||
"members.admin": "Yönetici",
|
||||
"members.adminTip": "Uygulama oluşturabilir ve takım ayarlarını yönetebilir",
|
||||
"members.alreadyInTeam": "Zaten ekipte",
|
||||
"members.alreadyInTeamTip": "Bu kullanıcıların bu çalışma alanına zaten erişimi var.",
|
||||
"members.builder": "Oluşturucu",
|
||||
"members.builderTip": "Kendi uygulamalarını oluşturup düzenleyebilir",
|
||||
"members.datasetOperator": "Bilgi Yöneticisi",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "{{role}} kullanıcısı olarak davet edildi",
|
||||
"members.lastActive": "SON AKTİF",
|
||||
"members.name": "İSİM",
|
||||
"members.noNewInvitationsSent": "Yeni davet gönderilmedi",
|
||||
"members.normal": "Normal",
|
||||
"members.normalTip": "Sadece uygulamaları kullanabilir, uygulama oluşturamaz",
|
||||
"members.ok": "Tamam",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Підключайте й керуйте MCP-серверами, щоб ваші застосунки мали доступ до зовнішніх інструментів і сервісів.",
|
||||
"members.admin": "Адміністратор",
|
||||
"members.adminTip": "Може створювати програми та керувати налаштуваннями команди",
|
||||
"members.alreadyInTeam": "Уже в команді",
|
||||
"members.alreadyInTeamTip": "Ці користувачі вже мають доступ до цього робочого простору.",
|
||||
"members.builder": "Будівник",
|
||||
"members.builderTip": "Може створювати та редагувати власні програми",
|
||||
"members.datasetOperator": "Адміністратор знань",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Запрошено як користувача {{role}}",
|
||||
"members.lastActive": "ОСТАННЯ АКТИВНІСТЬ",
|
||||
"members.name": "ІМ'Я",
|
||||
"members.noNewInvitationsSent": "Нові запрошення не надіслано",
|
||||
"members.normal": "Звичайний",
|
||||
"members.normalTip": "Може лише використовувати програми, не може створювати програми",
|
||||
"members.ok": "ОК",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "Kết nối và quản lý máy chủ MCP để ứng dụng của bạn truy cập các công cụ và dịch vụ bên ngoài.",
|
||||
"members.admin": "Quản trị viên",
|
||||
"members.adminTip": "Có thể xây dựng ứng dụng và quản lý cài đặt nhóm",
|
||||
"members.alreadyInTeam": "Đã ở trong nhóm",
|
||||
"members.alreadyInTeamTip": "Những người dùng này đã có quyền truy cập vào không gian làm việc này.",
|
||||
"members.builder": "Chủ thầu",
|
||||
"members.builderTip": "Có thể xây dựng và chỉnh sửa ứng dụng của riêng mình",
|
||||
"members.datasetOperator": "Quản trị viên kiến thức",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "Được mời với vai trò {{role}}",
|
||||
"members.lastActive": "HOẠT ĐỘNG GẦN ĐÂY",
|
||||
"members.name": "TÊN",
|
||||
"members.noNewInvitationsSent": "Không có lời mời mới nào được gửi",
|
||||
"members.normal": "Bình thường",
|
||||
"members.normalTip": "Chỉ có thể sử dụng ứng dụng, không thể xây dựng ứng dụng",
|
||||
"members.ok": "OK",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "连接并管理 MCP 服务器,让你的应用可以访问外部工具与服务。",
|
||||
"members.admin": "管理员",
|
||||
"members.adminTip": "能够建立应用程序和管理团队设置",
|
||||
"members.alreadyInTeam": "已在团队中",
|
||||
"members.alreadyInTeamTip": "以下用户已经可以访问此工作空间。",
|
||||
"members.builder": "构建器",
|
||||
"members.builderTip": "可以构建和编辑自己的应用程序",
|
||||
"members.datasetOperator": "知识库管理员",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "邀请为{{role}}用户",
|
||||
"members.lastActive": "上次活动时间",
|
||||
"members.name": "姓名",
|
||||
"members.noNewInvitationsSent": "没有新的邀请发送",
|
||||
"members.normal": "成员",
|
||||
"members.normalTip": "只能使用应用程序,不能建立应用程序",
|
||||
"members.ok": "好的",
|
||||
|
||||
@ -251,6 +251,8 @@
|
||||
"mcpPage.description": "連接並管理 MCP 伺服器,讓你的應用可以存取外部工具和服務。",
|
||||
"members.admin": "管理員",
|
||||
"members.adminTip": "能夠建立應用程式和管理團隊設定",
|
||||
"members.alreadyInTeam": "已在團隊中",
|
||||
"members.alreadyInTeamTip": "以下使用者已經可以存取此工作區。",
|
||||
"members.builder": "建築工人",
|
||||
"members.builderTip": "可以構建和編輯自己的應用程式",
|
||||
"members.datasetOperator": "知識管理員",
|
||||
@ -273,6 +275,7 @@
|
||||
"members.invitedAsRole": "邀請為{{role}}使用者",
|
||||
"members.lastActive": "上次活動時間",
|
||||
"members.name": "姓名",
|
||||
"members.noNewInvitationsSent": "沒有新的邀請送出",
|
||||
"members.normal": "成員",
|
||||
"members.normalTip": "只能使用應用程式,不能建立應用程式",
|
||||
"members.ok": "好的",
|
||||
|
||||
@ -171,6 +171,10 @@ export type InvitationResult = {
|
||||
status: 'success'
|
||||
email: string
|
||||
url: string
|
||||
} | {
|
||||
status: 'already_member'
|
||||
email: string
|
||||
message?: string
|
||||
} | {
|
||||
status: 'failed'
|
||||
email: string
|
||||
|
||||
@ -167,11 +167,26 @@ export const updatePluginProviderAIKey = ({ url, body }: { url: string, body: {
|
||||
return post<UpdateOpenAIKeyResponse>(url, { body })
|
||||
}
|
||||
|
||||
export const invitationCheck = ({ url, params }: { url: string, params: { workspace_id?: string, email?: string, token: string } }): Promise<CommonResponse & { is_valid: boolean, data: { workspace_name: string, email: string, workspace_id: string } }> => {
|
||||
return get<CommonResponse & { is_valid: boolean, data: { workspace_name: string, email: string, workspace_id: string } }>(url, { params })
|
||||
type InvitationCheckData = {
|
||||
workspace_name: string
|
||||
email: string
|
||||
workspace_id: string
|
||||
account_status?: string
|
||||
requires_setup?: boolean
|
||||
}
|
||||
|
||||
export const activateMember = ({ url, body }: { url: string, body: any }): Promise<LoginResponse> => {
|
||||
type ActivateMemberBody = {
|
||||
token: string
|
||||
name?: string
|
||||
interface_language?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export const invitationCheck = ({ url, params }: { url: string, params: { workspace_id?: string, email?: string, token: string } }): Promise<CommonResponse & { is_valid: boolean, data: InvitationCheckData }> => {
|
||||
return get<CommonResponse & { is_valid: boolean, data: InvitationCheckData }>(url, { params })
|
||||
}
|
||||
|
||||
export const activateMember = ({ url, body }: { url: string, body: ActivateMemberBody }): Promise<LoginResponse> => {
|
||||
return post<LoginResponse>(url, { body })
|
||||
}
|
||||
|
||||
|
||||
@ -248,7 +248,7 @@ export const useInvitationCheck = (params?: { workspace_id?: string, email?: str
|
||||
queryKey: commonQueryKeys.invitationCheck(params),
|
||||
queryFn: () => get<{
|
||||
is_valid: boolean
|
||||
data: { workspace_name: string, email: string, workspace_id: string }
|
||||
data: { workspace_name: string, email: string, workspace_id: string, account_status?: string, requires_setup?: boolean }
|
||||
result: string
|
||||
}>('/activate/check', { params }),
|
||||
enabled: enabled ?? !!params?.token,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user