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:
非法操作 2026-06-18 14:30:01 +08:00 committed by GitHub
parent 5873acc433
commit 26b0137c83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 786 additions and 180 deletions

View File

@ -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"}

View File

@ -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)})

View File

@ -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 |

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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": {

View File

@ -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
}

View File

@ -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(),
})

View File

@ -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()
})
})

View File

@ -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>

View 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')
})
})
})
})

View File

@ -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',
},
})
})
})
})
})

View File

@ -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"

View File

@ -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>
)}
</>
)}

View File

@ -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": "موافق",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "تایید",

View File

@ -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",

View File

@ -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": "ठीक है",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "확인",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "ОК",

View File

@ -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",

View File

@ -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": "ตกลง, ได้",

View File

@ -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",

View File

@ -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": "ОК",

View File

@ -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",

View File

@ -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": "好的",

View File

@ -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": "好的",

View File

@ -171,6 +171,10 @@ export type InvitationResult = {
status: 'success'
email: string
url: string
} | {
status: 'already_member'
email: string
message?: string
} | {
status: 'failed'
email: string

View File

@ -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 })
}

View File

@ -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,