fix: align member invitation flow and API contracts (#38856)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-13 23:16:23 +08:00 committed by GitHub
parent 5e635c059b
commit 323654afe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2228 additions and 891 deletions

View File

@ -1,4 +1,5 @@
from http import HTTPStatus
from typing import Annotated, Literal
from urllib import parse
from uuid import UUID
@ -43,7 +44,7 @@ from services.feature_service import FeatureService
class MemberInvitePayload(BaseModel):
emails: list[str] = Field(default_factory=list)
emails: list[str] = Field(min_length=1)
role: str
language: str | None = None
@ -70,11 +71,28 @@ class OwnerTransferPayload(BaseModel):
token: str
class MemberInviteResultResponse(ResponseModel):
status: str
class MemberInviteSuccessResponse(ResponseModel):
status: Literal["success"]
email: str
url: str | None = None
message: str | None = None
url: str
class MemberInviteAlreadyMemberResponse(ResponseModel):
status: Literal["already_member"]
email: str
message: str
class MemberInviteFailedResponse(ResponseModel):
status: Literal["failed"]
email: str
message: str
MemberInviteResultResponse = Annotated[
MemberInviteSuccessResponse | MemberInviteAlreadyMemberResponse | MemberInviteFailedResponse,
Field(discriminator="status"),
]
class MemberActionResponse(ResponseModel):
@ -83,7 +101,7 @@ class MemberActionResponse(ResponseModel):
class MemberInviteResponse(ResponseModel):
result: str
result: Literal["success"]
invitation_results: list[MemberInviteResultResponse]
tenant_id: str
@ -103,7 +121,9 @@ register_response_schema_models(
AccountWithRoleListResponse,
MemberActionResponse,
MemberInviteResponse,
MemberInviteResultResponse,
MemberInviteSuccessResponse,
MemberInviteAlreadyMemberResponse,
MemberInviteFailedResponse,
SimpleResultDataResponse,
SimpleResultResponse,
VerificationTokenResponse,
@ -279,7 +299,7 @@ class MemberInviteEmailApi(Resource):
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
MemberInviteResultResponse(
MemberInviteSuccessResponse(
status="success",
email=invitee_email,
url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
@ -287,7 +307,7 @@ class MemberInviteEmailApi(Resource):
)
except AccountAlreadyInTenantError:
invitation_results.append(
MemberInviteResultResponse(
MemberInviteAlreadyMemberResponse(
status="already_member",
email=invitee_email,
message="Account already in workspace.",
@ -295,14 +315,20 @@ class MemberInviteEmailApi(Resource):
)
except Exception as e:
invitation_results.append(
MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e))
MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e))
)
return MemberInviteResponse(
result="success",
invitation_results=invitation_results,
tenant_id=inviter.current_tenant.id if inviter.current_tenant else "",
).model_dump(mode="json"), HTTPStatus.CREATED
return (
dump_response(
MemberInviteResponse,
{
"result": "success",
"invitation_results": invitation_results,
"tenant_id": inviter.current_tenant.id if inviter.current_tenant else "",
},
),
HTTPStatus.CREATED,
)
@console_ns.route("/workspaces/current/members/<uuid:member_id>")

View File

@ -109,11 +109,15 @@ def _replace_schema_table_type(markdown: str, definition_name: str, row_name: st
def _has_union_schema(schema: object) -> bool:
return isinstance(schema, dict) and (isinstance(schema.get("oneOf"), list) or isinstance(schema.get("anyOf"), list))
if not isinstance(schema, dict):
return False
if isinstance(schema.get("oneOf"), list) or isinstance(schema.get("anyOf"), list):
return True
return _has_union_schema(schema.get("items"))
def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str:
"""Fill Markdown table cells that `swagger-markdown` leaves blank for union schemas."""
"""Fill Markdown table cells that `swagger-markdown` leaves blank for unions, including array items."""
spec = json.loads(spec_path.read_text(encoding="utf-8"))
components = spec.get("components")

View File

@ -18641,11 +18641,27 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| data | [ [AccessPolicyMemberBinding](#accesspolicymemberbinding) ] | | No |
#### MemberInviteAlreadyMemberResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| message | string | | Yes |
| status | string | | Yes |
#### MemberInviteFailedResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| message | string | | Yes |
| status | string | | Yes |
#### MemberInvitePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| emails | [ string ] | | No |
| emails | [ string ] | | Yes |
| language | string | | No |
| role | string | | Yes |
@ -18653,18 +18669,17 @@ Enum class for large language model mode.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| invitation_results | [ [MemberInviteResultResponse](#memberinviteresultresponse) ] | | Yes |
| invitation_results | [ [MemberInviteSuccessResponse](#memberinvitesuccessresponse)<br>[MemberInviteAlreadyMemberResponse](#memberinvitealreadymemberresponse)<br>[MemberInviteFailedResponse](#memberinvitefailedresponse) ] | | Yes |
| result | string | | Yes |
| tenant_id | string | | Yes |
#### MemberInviteResultResponse
#### MemberInviteSuccessResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| message | string | | No |
| status | string | | Yes |
| url | string | | No |
| url | string | | Yes |
#### MemberRoleUpdatePayload

View File

@ -730,7 +730,7 @@ future server adds a code. Formatter tests pin emitted values to the enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| loc | [ ], <br>**Default:** | | No |
| loc | [ string<br>integer ] | | No |
| msg | string | | Yes |
| type | string | | Yes |

View File

@ -3609,7 +3609,7 @@ Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| datasource_info_list | [ ] | List of datasource objects to process. The expected item structure depends on `datasource_type`. | Yes |
| datasource_info_list | [ object<br>object<br>object<br>object ] | List of datasource objects to process. The expected item structure depends on `datasource_type`. | Yes |
| datasource_type | string, <br>**Available values:** "local_file", "online_document", "online_drive", "website_crawl" | Type of the datasource. Determines which fields are expected in `datasource_info_list` items.<br>*Enum:* `"local_file"`, `"online_document"`, `"online_drive"`, `"website_crawl"` | Yes |
| inputs | object | Key-value pairs for pipeline input variables defined in the workflow. Pass `{}` if the pipeline has no input variables. | Yes |
| is_published | boolean | Whether to run the published or draft version of the pipeline. `true` runs the latest published version; `false` runs the current draft (useful for testing unpublished changes). | Yes |

View File

@ -237,6 +237,51 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat
assert "| value | string<br>integer<br>number<br>boolean | | No |" in patched
def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-openapi.json"
spec_path.write_text(
json.dumps(
{
"components": {
"schemas": {
"MemberInviteResponse": {
"properties": {
"invitation_results": {
"type": "array",
"items": {
"oneOf": [
{"$ref": "#/components/schemas/MemberInviteSuccessResponse"},
{"$ref": "#/components/schemas/MemberInviteAlreadyMemberResponse"},
{"$ref": "#/components/schemas/MemberInviteFailedResponse"},
],
},
},
},
},
},
}
}
),
encoding="utf-8",
)
markdown = """#### MemberInviteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| invitation_results | [ ] | | Yes |
"""
patched = module._patch_union_schema_markdown(markdown, spec_path)
assert (
"| invitation_results | [ "
"[MemberInviteSuccessResponse](#memberinvitesuccessresponse)<br>"
"[MemberInviteAlreadyMemberResponse](#memberinvitealreadymemberresponse)<br>"
"[MemberInviteFailedResponse](#memberinvitefailedresponse) ] | | Yes |"
) in patched
def test_patch_union_schema_markdown_ignores_specs_without_schemas(tmp_path):
module = _load_generate_swagger_markdown_docs_module()
spec_path = tmp_path / "console-openapi.json"

View File

@ -3331,11 +3331,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"jsx_a11y/no-autofocus": {
"count": 1
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -6888,7 +6883,7 @@
"count": 1
},
"typescript/no-explicit-any": {
"count": 9
"count": 8
}
},
"web/service/datasets.ts": {

View File

@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"check": "vp check && pnpm lint:eslint",
"check": "vp fmt --check && vp lint --quiet && pnpm lint:eslint",
"check:fix": "pnpm lint:eslint:fix && vp check --fix",
"dev": "concurrently -k -n vinext,proxy \"vp run dify-web#dev:vinext\" \"vp run dify-web#dev:proxy\"",
"prepare": "vp config",

View File

@ -161,14 +161,24 @@ export type EndpointUpdatePayload = {
}
export type MemberInvitePayload = {
emails?: Array<string>
emails: Array<string>
language?: string | null
role: string
}
export type MemberInviteResponse = {
invitation_results: Array<MemberInviteResultResponse>
result: string
invitation_results: Array<
| ({
status: 'success'
} & MemberInviteSuccessResponse)
| ({
status: 'already_member'
} & MemberInviteAlreadyMemberResponse)
| ({
status: 'failed'
} & MemberInviteFailedResponse)
>
result: 'success'
tenant_id: string
}
@ -1128,11 +1138,22 @@ export type EndpointListItemResponse = {
url: string
}
export type MemberInviteResultResponse = {
export type MemberInviteSuccessResponse = {
email: string
message?: string | null
status: string
url?: string | null
status: 'success'
url: string
}
export type MemberInviteAlreadyMemberResponse = {
email: string
message: string
status: 'already_member'
}
export type MemberInviteFailedResponse = {
email: string
message: string
status: 'failed'
}
export type ProviderResponse = {

View File

@ -90,7 +90,7 @@ export const zEndpointUpdatePayload = z.object({
* MemberInvitePayload
*/
export const zMemberInvitePayload = z.object({
emails: z.array(z.string()).optional(),
emails: z.array(z.string()).min(1),
language: z.string().nullish(),
role: z.string(),
})
@ -816,21 +816,56 @@ export const zAccountWithRoleListResponse = z.object({
})
/**
* MemberInviteResultResponse
* MemberInviteSuccessResponse
*/
export const zMemberInviteResultResponse = z.object({
export const zMemberInviteSuccessResponse = z.object({
email: z.string(),
message: z.string().nullish(),
status: z.string(),
url: z.string().nullish(),
status: z.literal('success'),
url: z.string(),
})
/**
* MemberInviteAlreadyMemberResponse
*/
export const zMemberInviteAlreadyMemberResponse = z.object({
email: z.string(),
message: z.string(),
status: z.literal('already_member'),
})
/**
* MemberInviteFailedResponse
*/
export const zMemberInviteFailedResponse = z.object({
email: z.string(),
message: z.string(),
status: z.literal('failed'),
})
/**
* MemberInviteResponse
*/
export const zMemberInviteResponse = z.object({
invitation_results: z.array(zMemberInviteResultResponse),
result: z.string(),
invitation_results: z.array(
z.union([
z
.object({
status: z.literal('success'),
})
.and(zMemberInviteSuccessResponse),
z
.object({
status: z.literal('already_member'),
})
.and(zMemberInviteAlreadyMemberResponse),
z
.object({
status: z.literal('failed'),
})
.and(zMemberInviteFailedResponse),
]),
),
result: z.literal('success'),
tenant_id: z.string(),
})

18
pnpm-lock.yaml generated
View File

@ -525,9 +525,6 @@ catalogs:
react-i18next:
specifier: 17.0.9
version: 17.0.9
react-multi-email:
specifier: 1.0.25
version: 1.0.25
react-papaparse:
specifier: 4.4.0
version: 4.4.0
@ -1413,9 +1410,6 @@ importers:
react-i18next:
specifier: 'catalog:'
version: 17.0.9(@typescript/typescript6@6.0.2)(i18next@26.3.6(@typescript/typescript6@6.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-multi-email:
specifier: 'catalog:'
version: 1.0.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-papaparse:
specifier: 'catalog:'
version: 4.4.0
@ -8544,12 +8538,6 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-multi-email@1.0.25:
resolution: {integrity: sha512-Wmv28FvIk4nWgdpHzlIPonY4iSs7bPV35+fAiWYzSBhTo+vhXfglEhjY1WnjHQINW/Pibu2xlb/q1heVuytQHQ==}
peerDependencies:
react: ^18.2.0
react-dom: ^18.2.0
react-papaparse@4.4.0:
resolution: {integrity: sha512-xTEwHZYJ+1dh9mQDQjjwJXmWyX20DdZ52u+ddw75V+Xm5qsjXSvWmC7c8K82vRwMjKAOH2S9uFyGpHEyEztkUQ==}
engines: {node: '>=8', npm: '>=5'}
@ -17465,11 +17453,6 @@ snapshots:
react-is@17.0.2: {}
react-multi-email@1.0.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-papaparse@4.4.0:
dependencies:
'@types/papaparse': 5.5.2
@ -19387,7 +19370,6 @@ time:
react-dom@19.2.7: '2026-06-01T18:01:02.438Z'
react-easy-crop@6.2.2: '2026-07-08T08:37:49.604Z'
react-i18next@17.0.9: '2026-07-09T13:42:03.224Z'
react-multi-email@1.0.25: '2024-07-18T04:31:06.176Z'
react-papaparse@4.4.0: '2023-10-13T10:27:07.978Z'
react-pdf-highlighter@8.0.0-rc.0: '2024-09-14T16:57:58.673Z'
react-server-dom-webpack@19.2.7: '2026-06-01T18:01:09.215Z'

View File

@ -224,7 +224,6 @@ catalog:
react-dom: 19.2.7
react-easy-crop: 6.2.2
react-i18next: 17.0.9
react-multi-email: 1.0.25
react-papaparse: 4.4.0
react-pdf-highlighter: 8.0.0-rc.0
react-server-dom-webpack: 19.2.7

View File

@ -1,3 +1,4 @@
import type { ReactElement } from 'react'
import type { AppContextStateMockState } from '@/__tests__/utils/mock-app-context-state'
import type { Role } from '@/models/access-control'
import type { ICurrentWorkspace, Member } from '@/models/common'
@ -84,31 +85,36 @@ vi.mock('../edit-workspace-modal', () => ({
</div>
),
}))
vi.mock('../invite-button', () => ({
default: ({ onClick, disabled }: { onClick: () => void; disabled: boolean }) => (
<button onClick={onClick} disabled={disabled}>
Invite
</button>
),
}))
vi.mock('../invite-modal', () => ({
default: ({
onCancel,
InviteModal: ({
open,
trigger,
onOpenChange,
onSend,
}: {
onCancel: () => void
open: boolean
trigger: ReactElement<{ disabled?: boolean }>
onOpenChange: (open: boolean) => void
onSend: (results: Array<{ email: string; status: 'success'; url: string }>) => void
}) => (
<div>
<div>Invite Modal</div>
<button onClick={onCancel}>Close Invite Modal</button>
<button
onClick={() =>
onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])
}
>
Send Invite Results
<button disabled={trigger.props.disabled} onClick={() => onOpenChange(true)}>
Invite
</button>
{open && (
<div>
<div>Invite Modal</div>
<button onClick={() => onOpenChange(false)}>Close Invite Modal</button>
<button
onClick={() => {
onOpenChange(false)
onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])
}}
>
Send Invite Results
</button>
</div>
)}
</div>
),
}))
@ -321,7 +327,6 @@ describe('MembersPage', () => {
await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
expect(screen.getByText('Invited Modal'))!.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Close Invited Modal' }))
expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument()
@ -723,7 +728,8 @@ describe('MembersPage', () => {
expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
it('should show the upgrade action without blocking the backend-authoritative invite flow', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContext).mockReturnValue(
createMockProviderContextValue({
enableBilling: true,
@ -739,5 +745,7 @@ describe('MembersPage', () => {
renderMembersPage()
expect(screen.getByText('Upgrade Button'))!.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Invite' }))
expect(screen.getByText('Invite Modal')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,7 @@
'use client'
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { Role } from '@/models/access-control'
import type { InvitationResult, Member } from '@/models/common'
import type { Member } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -22,7 +23,7 @@ import { useMembers } from '@/service/use-common'
import { hasPermission } from '@/utils/permission'
import EditWorkspaceModal from './edit-workspace-modal'
import InviteButton from './invite-button'
import InviteModal from './invite-modal'
import { InviteModal } from './invite-modal'
import InvitedModal from './invited-modal'
import MemberDetailsModal from './member-details-modal'
import MemberRow from './member-row'
@ -40,7 +41,9 @@ const MembersPage = () => {
const { data, refetch } = useMembers(language)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationResults, setInvitationResults] = useState<InvitationResult[]>([])
const [invitationResults, setInvitationResults] = useState<
MemberInviteResponse['invitation_results']
>([])
const [invitedModalVisible, setInvitedModalVisible] = useState(false)
const accounts = data?.accounts || []
const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext()
@ -158,7 +161,16 @@ const MembersPage = () => {
{isMemberFull && <UpgradeBtn className="mr-2" loc="member-invite" />}
<div className="shrink-0">
{canManageMembers && (
<InviteButton disabled={isMemberFull} onClick={() => setInviteModalVisible(true)} />
<InviteModal
open={inviteModalVisible}
trigger={<InviteButton />}
isEmailSetup={systemFeatures.is_email_setup}
onOpenChange={setInviteModalVisible}
onSend={(invitationResults) => {
setInvitedModalVisible(true)
setInvitationResults(invitationResults)
}}
/>
)}
</div>
</div>
@ -191,17 +203,6 @@ const MembersPage = () => {
</div>
</div>
</div>
{inviteModalVisible && (
<InviteModal
isEmailSetup={systemFeatures.is_email_setup}
onCancel={() => setInviteModalVisible(false)}
onSend={(invitationResults) => {
setInvitedModalVisible(true)
setInvitationResults(invitationResults)
refetch()
}}
/>
)}
{invitedModalVisible && (
<InvitedModal
invitationResults={invitationResults}

View File

@ -1,5 +1,5 @@
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { RiUserAddLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
@ -8,10 +8,7 @@ import { currentWorkspaceIdAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissions } from '@/service/use-workspace'
type InviteButtonProps = {
disabled?: boolean
onClick?: () => void
}
type InviteButtonProps = Omit<ButtonProps, 'children' | 'variant'>
const InviteButton = (props: InviteButtonProps) => {
const { t } = useTranslation()
@ -28,8 +25,8 @@ const InviteButton = (props: InviteButtonProps) => {
}
}
return (
<Button variant="primary" {...props}>
<RiUserAddLine className="mr-1 size-4" />
<Button {...props} variant="primary">
<span aria-hidden="true" className="mr-1 i-ri-user-add-line size-4" />
{t(($) => $['members.invite'], { ns: 'common' })}
</Button>
)

View File

@ -0,0 +1,345 @@
import type { EmailRecipient } from '../email-recipients'
import { createEvent, fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { EmailRecipientsField } from '../email-recipients-field'
function EmailRecipientsFieldWrapper({ disabled = false }: { disabled?: boolean }) {
const [recipients, setRecipients] = useState<EmailRecipient[]>([])
const [draft, setDraft] = useState('')
return (
<>
<EmailRecipientsField
recipients={recipients}
draft={draft}
onRecipientsChange={setRecipients}
onDraftChange={setDraft}
disabled={disabled}
/>
<button type="button">Next control</button>
</>
)
}
function getRecipient(value: string) {
const recipient = screen.getAllByRole('listitem').find((item) => within(item).queryByText(value))
expect(recipient).toBeDefined()
return recipient!
}
function getChip(value: string) {
return screen.getByRole('button', { name: value })
}
describe('EmailRecipientsField', () => {
it('describes how to add multiple email addresses', () => {
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
expect(input).toHaveAttribute('type', 'text')
expect(input).toHaveAttribute('inputmode', 'email')
expect(input).toHaveAccessibleDescription(/members\.emailRecipientsTip/i)
})
it('keeps an uncommitted draft when focus leaves the composer', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'draft@example.com')
await user.tab()
expect(input).toHaveValue('draft@example.com')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus()
})
it('should keep a single pasted address in the draft for further editing', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('Person@Example.com')
expect(input).toHaveValue('Person@Example.com')
expect(input).toHaveFocus()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('commits a valid draft with Enter and keeps focus in the composer', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'person@example{Enter}')
expect(getRecipient('person@example')).toBeInTheDocument()
expect(input).toHaveValue('')
expect(input).toHaveFocus()
})
it('keeps a manually entered invalid address in the input for correction', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'not-an-email{Enter}')
expect(input).toHaveValue('not-an-email')
expect(input).toHaveFocus()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument()
})
it('does not commit or submit Enter while an IME composition is active', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'person@example.com')
const enter = createEvent.keyDown(input, { key: 'Enter', isComposing: true })
fireEvent(input, enter)
expect(enter).toHaveProperty('defaultPrevented', true)
expect(input).toHaveValue('person@example.com')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('should wait for Enter before committing manually typed separators', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'first@example.com,second@example.com;third@example.com')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
await user.keyboard('{Enter}')
expect(screen.getAllByRole('listitem')).toHaveLength(3)
expect(input).toHaveValue('')
expect(input).toHaveFocus()
})
it('should not report a valid delimited draft as invalid when focus leaves', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'first@example.com,second@example.com')
await user.tab()
expect(input).toHaveValue('first@example.com,second@example.com')
expect(input).not.toHaveAttribute('aria-invalid', 'true')
expect(screen.queryByText(/members\.emailInvalid/i)).not.toBeInTheDocument()
})
it('should not split manually typed addresses on spaces', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'first@example.com second@example.com{Enter}')
expect(input).toHaveValue('first@example.com second@example.com')
expect(input).toHaveFocus()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument()
})
it('parses batch paste delimiters and removes duplicates case-insensitively', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste(
'First@Example.com, second@example.com; FIRST@example.com\nthird@example.com\tfourth@example.com',
)
const recipientList = screen.getByRole('list', { name: /members\.emailRecipients/i })
expect(within(recipientList).getAllByRole('listitem')).toHaveLength(4)
expect(within(recipientList).getByText('first@example.com')).toBeInTheDocument()
expect(within(recipientList).getByText('second@example.com')).toBeInTheDocument()
expect(within(recipientList).getByText('third@example.com')).toBeInTheDocument()
expect(within(recipientList).getByText('fourth@example.com')).toBeInTheDocument()
})
it('should combine a batch paste with the draft at the current caret', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'first@example.com')
await user.paste(', second@example.com')
expect(screen.getAllByRole('listitem')).toHaveLength(2)
expect(getRecipient('first@example.com')).toBeInTheDocument()
expect(getRecipient('second@example.com')).toBeInTheDocument()
expect(input).toHaveValue('')
})
it('should replace a selected draft when a batch is pasted', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'stale@example.com')
await user.tripleClick(input)
await user.paste('first@example.com, second@example.com')
expect(screen.getAllByRole('listitem')).toHaveLength(2)
expect(getRecipient('first@example.com')).toBeInTheDocument()
expect(getRecipient('second@example.com')).toBeInTheDocument()
expect(screen.queryByText('stale@example.com')).not.toBeInTheDocument()
expect(input).toHaveValue('')
})
it('should deduplicate a later batch against existing recipients', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('first@example.com,')
await user.paste('FIRST@example.com, second@example.com')
expect(screen.getAllByRole('listitem')).toHaveLength(2)
expect(getRecipient('first@example.com')).toBeInTheDocument()
expect(getRecipient('second@example.com')).toBeInTheDocument()
})
it('moves an invalid pasted address back into the input for editing', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('valid@example.com, not-an-email')
await user.click(screen.getByRole('button', { name: /operation\.edit.*not-an-email/i }))
expect(screen.queryByText('not-an-email')).not.toBeInTheDocument()
expect(input).toHaveValue('not-an-email')
expect(input).toHaveFocus()
expect(getRecipient('valid@example.com')).toBeInTheDocument()
})
it('preserves the draft and restores input focus when a recipient is removed', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'existing@example.com{Enter}draft@example.com')
await user.click(
screen.getByRole('button', { name: /operation\.remove.*existing@example\.com/i }),
)
expect(screen.queryByText('existing@example.com')).not.toBeInTheDocument()
expect(input).toHaveValue('draft@example.com')
expect(input).toHaveFocus()
})
it('supports one keyboard path through chips without adding tab stops', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('first@example.com, second@example.com')
await user.keyboard('{ArrowLeft}')
expect(getChip('second@example.com')).toHaveFocus()
await user.keyboard('{ArrowLeft}')
expect(getChip('first@example.com')).toHaveFocus()
await user.keyboard('{ArrowRight}{Delete}')
expect(screen.queryByText('second@example.com')).not.toBeInTheDocument()
expect(getChip('first@example.com')).toHaveFocus()
await user.keyboard('{Backspace}')
expect(screen.queryByText('first@example.com')).not.toBeInTheDocument()
expect(input).toHaveFocus()
await user.type(input, 'last@example.com{Enter}')
await user.tab()
expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus()
})
it('should return focus to the input after moving past the last chip', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('first@example.com, second@example.com')
await user.keyboard('{ArrowLeft}{ArrowRight}')
expect(input).toHaveFocus()
})
it('should focus the next chip after deleting a middle chip', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('first@example.com, second@example.com, third@example.com')
await user.keyboard('{ArrowLeft}{ArrowLeft}{Delete}')
expect(screen.queryByText('second@example.com')).not.toBeInTheDocument()
expect(getChip('third@example.com')).toHaveFocus()
})
it.each(['{Enter}', ' '])(
'should return focus to the input when a valid chip is activated with %j',
async (key) => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('person@example.com,')
await user.keyboard(`{ArrowLeft}${key}`)
expect(getRecipient('person@example.com')).toBeInTheDocument()
expect(input).toHaveFocus()
},
)
it('should move an invalid chip into the focused input when activated from the keyboard', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste('valid@example.com, not-an-email')
await user.keyboard('{ArrowLeft}{Enter}')
expect(screen.queryByText('not-an-email')).not.toBeInTheDocument()
expect(input).toHaveValue('not-an-email')
expect(input).toHaveFocus()
expect(getRecipient('valid@example.com')).toBeInTheDocument()
})
it('immediately removes the last chip with Backspace from an empty input', async () => {
const user = userEvent.setup()
render(<EmailRecipientsFieldWrapper />)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'person@example.com{Enter}{Backspace}')
expect(screen.queryByText('person@example.com')).not.toBeInTheDocument()
expect(input).toHaveFocus()
})
it('disables every editing action while the composer is frozen', () => {
render(<EmailRecipientsFieldWrapper disabled />)
expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toBeDisabled()
})
})

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { createEmailRecipient, hasEmailDelimiter, mergeEmailRecipients } from '../email-recipients'
describe('email recipients', () => {
it('should normalize an address while preserving browser email validity semantics', () => {
expect(createEmailRecipient(' Person@Example ')).toEqual({
value: 'person@example',
isValid: true,
})
expect(createEmailRecipient('not-an-email')).toEqual({
value: 'not-an-email',
isValid: false,
})
})
it.each([',', ';', '\n', '\r\n', '\t'])(
'should recognize %j as a batch paste separator',
(separator) => {
expect(hasEmailDelimiter(`first@example.com${separator}second@example.com`)).toBe(true)
},
)
it.each(['first@example.com', 'first@example.com second@example.com'])(
'should not treat %j as batch input',
(value) => {
expect(hasEmailDelimiter(value)).toBe(false)
},
)
it('should ignore empty entries while preserving recipient order', () => {
expect(mergeEmailRecipients([], ' first@example.com, ,\nsecond@example.com;\t')).toEqual([
{ value: 'first@example.com', isValid: true },
{ value: 'second@example.com', isValid: true },
])
})
it('should deduplicate recipients against both existing and pasted addresses', () => {
const existing = [createEmailRecipient('first@example.com')]
expect(
mergeEmailRecipients(existing, 'FIRST@example.com, second@example.com, SECOND@example.com'),
).toEqual([
{ value: 'first@example.com', isValid: true },
{ value: 'second@example.com', isValid: true },
])
})
it('should preserve invalid pasted entries so the user can correct them', () => {
expect(mergeEmailRecipients([], 'valid@example.com, not-an-email')).toEqual([
{ value: 'valid@example.com', isValid: true },
{ value: 'not-an-email', isValid: false },
])
})
it('should preserve the order of a realistic twenty-recipient batch', () => {
const addresses = Array.from({ length: 20 }, (_, index) => `person-${index + 1}@example.com`)
expect(mergeEmailRecipients([], addresses.join('\n')).map(({ value }) => value)).toEqual(
addresses,
)
})
})

View File

@ -1,84 +1,50 @@
import type { InvitationResponse } from '@/models/common'
import { toast } from '@langgenius/dify-ui/toast'
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useProviderContextSelector } from '@/context/provider-context'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import { inviteMember } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import InviteModal from '../index'
import { InviteModal } from '../index'
const { mockToastError } = vi.hoisted(() => ({
mockToastError: vi.fn(),
}))
const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() }))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
useProviderContext: vi.fn(() => ({
datasetOperatorEnabled: true,
})),
}))
vi.mock('@/service/common')
vi.mock('@/service/access-control/use-workspace-roles')
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mockToastError,
vi.mock('@/service/client', () => ({
consoleQuery: {
workspaces: {
current: {
members: {
inviteEmail: {
post: {
mutationOptions: () => ({ mutationFn: inviteMember }),
},
},
},
},
},
},
}))
vi.mock('react-multi-email', () => ({
ReactMultiEmail: ({
emails,
onChange,
getLabel,
}: {
emails: string[]
onChange: (emails: string[]) => void
getLabel: (
email: string,
index: number,
removeEmail: (index: number) => void,
) => React.ReactNode
}) => (
<div>
<input
data-testid="mock-email-input"
onChange={(e) => onChange(e.target.value ? e.target.value.split(',') : [])}
/>
{emails.map((email: string, index: number) => (
<div key={email}>
{getLabel(email, index, (idx: number) =>
onChange(emails.filter((_: string, i: number) => i !== idx)),
)}
</div>
))}
</div>
),
}))
describe('InviteModal', () => {
const mockOnCancel = vi.fn()
const mockOnSend = vi.fn()
const mockRefreshLicenseLimit = vi.fn()
const onOpenChange = vi.fn()
const onSend = vi.fn()
const refreshLicenseLimit = vi.fn()
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
},
mutations: {
retry: false,
},
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
})
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useWorkspaceRoleList).mockReturnValue({
data: {
pages: [
@ -95,20 +61,9 @@ describe('InviteModal', () => {
permission_keys: [],
role_tag: '',
},
{
id: 'normal',
tenant_id: 'tenant-id',
type: 'workspace',
category: 'global_system_default',
name: 'Normal',
description: 'Can use apps',
is_builtin: true,
permission_keys: [],
role_tag: '',
},
],
pagination: {
total_count: 2,
total_count: 1,
per_page: 20,
current_page: 1,
total_pages: 1,
@ -123,112 +78,137 @@ describe('InviteModal', () => {
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 5, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
refreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
})
const renderModal = (isEmailSetup = true, queryClient = createQueryClient()) => ({
queryClient,
...render(
const renderModal = ({
open = true,
isEmailSetup = true,
queryClient = createQueryClient(),
}: {
open?: boolean
isEmailSetup?: boolean
queryClient?: QueryClient
} = {}) =>
render(
<QueryClientProvider client={queryClient}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
<InviteModal
open={open}
trigger={<button type="button">members.invite</button>}
isEmailSetup={isEmailSetup}
onOpenChange={onOpenChange}
onSend={onSend}
/>
</QueryClientProvider>,
),
})
const fillEmails = (value: string) => {
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
}
)
const selectAdminRole = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /members\.selectRole/i }))
await user.click(screen.getByRole('menuitemradio', { name: /Admin/i }))
await user.click(screen.getByRole('combobox', { name: /members\.role/i }))
await user.click(screen.getByRole('option', { name: /Admin/i }))
}
it('should render invite modal content', async () => {
const addRecipients = async (user: ReturnType<typeof userEvent.setup>, value: string) => {
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.click(input)
await user.paste(value)
}
it('renders a labeled form inside a controlled dialog', () => {
renderModal()
expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
const dialog = screen.getByRole('dialog', { name: /members\.inviteTeamMember$/i })
expect(within(dialog).getByText(/members\.inviteTeamMemberTip/i)).toBeInTheDocument()
expect(within(dialog).getByRole('form')).toBeInTheDocument()
expect(
within(dialog).getByRole('textbox', { name: /members\.emailRecipients/i }),
).toHaveAttribute('inputmode', 'email')
})
it('should show warning when email service is not configured', async () => {
renderModal(false)
expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument()
})
it('should enable send button after entering an email and selecting a role', async () => {
it('should place initial focus in the email composer', async () => {
renderModal()
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
const user = userEvent.setup()
await selectAdminRole(user)
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
it('should not close modal when invite request fails', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockRejectedValue(new Error('request failed'))
renderModal()
fillEmails('user@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toHaveFocus()
})
})
it('should send invites and close modal on successful submission', async () => {
it('should focus the email field first when the untouched form is submitted with Enter', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'success',
invitation_results: [],
} as InvitationResponse)
renderModal()
fillEmails('user@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await waitFor(() => expect(input).toHaveFocus())
await user.keyboard('{Enter}')
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockRefreshLicenseLimit).toHaveBeenCalled()
expect(mockOnCancel).toHaveBeenCalled()
expect(mockOnSend).toHaveBeenCalled()
})
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByText(/members\.emailRequired/i)).toBeInTheDocument()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should submit the selected workspace role id', async () => {
it('does not render dialog content while controlled closed', () => {
renderModal({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('shows the email service warning in the form', () => {
renderModal({ isEmailSetup: false })
expect(screen.getByText(/members\.emailNotSetup/i)).toBeInTheDocument()
})
it('submits normalized, deduplicated recipients with the selected Role id', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
} as InvitationResponse)
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
fillEmails('user@example.com')
await addRecipients(user, 'First@Example.com, second@example.com; first@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await user.click(screen.getByRole('button', { name: /members\.sendInviteCount/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalledWith({
url: '/workspaces/current/members/invite-email',
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['user@example.com'],
emails: ['first@example.com', 'second@example.com'],
role: 'admin',
language: 'en-US',
},
})
})
expect(refreshLicenseLimit).toHaveBeenCalled()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onSend).toHaveBeenCalledWith([])
})
it('submits a valid draft without requiring Enter or blur to create a chip', async () => {
const user = userEvent.setup()
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
await selectAdminRole(user)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'draft@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalledOnce()
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['draft@example.com'],
role: 'admin',
language: 'en-US',
},
@ -236,238 +216,407 @@ describe('InviteModal', () => {
})
})
it('should invalidate members after successful submission', async () => {
it('should submit a manually typed email list without requiring Enter', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const membersQueryKey = [...commonQueryKeys.members, 'en-US']
queryClient.setQueryData(membersQueryKey, { accounts: [] })
vi.mocked(inviteMember).mockResolvedValue({
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
} as InvitationResponse)
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
renderModal(true, queryClient)
fillEmails('user@example.com')
await selectAdminRole(user)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'first@gmail.com,second@gmail.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true)
expect(inviteMember).toHaveBeenCalledOnce()
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['first@gmail.com', 'second@gmail.com'],
role: 'admin',
language: 'en-US',
},
})
})
})
it('should keep send button disabled when license limit is exceeded', async () => {
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
it('should create exactly one non-empty recipient when a single draft is submitted', async () => {
const user = userEvent.setup()
let resolveInvite!: (response: MemberInviteResponse) => void
inviteMember.mockReturnValue(
new Promise<MemberInviteResponse>((resolve) => {
resolveInvite = resolve
}),
)
renderModal()
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByRole('button', { name: /Close|operation.close/ }))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should show error notification for invalid email submission', async () => {
const user = userEvent.setup()
renderModal()
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
fillEmails('invalid@email.c')
await selectAdminRole(user)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'only@gmail.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid')
expect(inviteMember).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(1)
expect(screen.getByText('only@gmail.com')).toBeInTheDocument()
expect(input).toHaveValue('')
})
await act(async () => {
resolveInvite({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
})
})
})
it('should remove email from list when remove icon is clicked', async () => {
it('should commit a non-empty draft before Enter submits the form', async () => {
const user = userEvent.setup()
renderModal()
fillEmails('user@example.com')
expect(screen.getByText('user@example.com')).toBeInTheDocument()
const removeBtn = screen.getByRole('button', { name: /operation\.remove.*user@example\.com/i })
await user.click(removeBtn)
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
})
it('should show unlimited label when workspace member limit is zero', async () => {
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
renderModal()
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
})
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: null, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
renderModal()
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
expect(await screen.findByText('0')).toBeInTheDocument()
})
it('should not call onSend when invite result is not success', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'error',
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
} as unknown as InvitationResponse)
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
fillEmails('user@example.com')
await selectAdminRole(user)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'draft@example.com{Enter}')
expect(screen.getByText('draft@example.com')).toBeInTheDocument()
expect(input).toHaveValue('')
expect(input).toHaveFocus()
expect(inviteMember).not.toHaveBeenCalled()
await user.keyboard('{Enter}')
await waitFor(() => {
expect(inviteMember).toHaveBeenCalledOnce()
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['draft@example.com'],
role: 'admin',
language: 'en-US',
},
})
})
})
it('accepts an address allowed by the browser without requiring a dotted domain', async () => {
const user = userEvent.setup()
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'person@example{Enter}')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => expect(inviteMember).toHaveBeenCalled())
})
it('keeps invalid recipients visible and blocks the whole submission', async () => {
const user = userEvent.setup()
renderModal()
await addRecipients(user, 'valid@example.com, invalid-email')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(screen.getByText('invalid-email')).toBeInTheDocument()
expect(screen.getAllByText(/members\.emailInvalid/i)).not.toHaveLength(0)
expect(inviteMember).not.toHaveBeenCalled()
})
it('should preserve and refocus an invalid draft until the user corrects it', async () => {
const user = userEvent.setup()
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
await selectAdminRole(user)
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'not-an-email')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(input).toHaveValue('not-an-email')
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument()
expect(inviteMember).not.toHaveBeenCalled()
await user.clear(input)
await user.type(input, 'corrected@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalledOnce()
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['corrected@example.com'],
role: 'admin',
language: 'en-US',
},
})
})
})
it('shows the required error and focuses the email field after an empty submission', async () => {
const user = userEvent.setup()
renderModal()
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(screen.getByText(/members\.emailRequired/i)).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toHaveFocus()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should preserve the email draft while the user resolves a missing role', async () => {
const user = userEvent.setup()
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'draft@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
const roleTrigger = screen.getByRole('combobox', { name: /members\.role/i })
expect(roleTrigger).toHaveFocus()
expect(roleTrigger).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveValue('draft@example.com')
expect(inviteMember).not.toHaveBeenCalled()
await user.click(roleTrigger)
await user.click(screen.getByRole('option', { name: /Admin/i }))
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalledOnce()
expect(inviteMember.mock.calls[0]?.[0]).toEqual({
body: {
emails: ['draft@example.com'],
role: 'admin',
language: 'en-US',
},
})
})
})
it('freezes all editable controls while invitations are being sent', async () => {
const user = userEvent.setup()
let resolveInvite!: (response: MemberInviteResponse) => void
inviteMember.mockReturnValue(
new Promise<MemberInviteResponse>((resolve) => {
resolveInvite = resolve
}),
)
renderModal()
await addRecipients(user, 'user@example.com, another@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toBeDisabled()
expect(screen.getByRole('combobox', { name: /members\.role/i })).toBeDisabled()
expect(
screen.getByRole('button', { name: /operation\.remove.*user@example\.com/i }),
).toBeDisabled()
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toHaveAttribute(
'aria-disabled',
'true',
)
})
expect(inviteMember).toHaveBeenCalledOnce()
await act(async () => {
resolveInvite({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
})
})
})
it('should show destructive text color when used size exceeds limit', async () => {
it('warns but lets the backend decide whether recipients consume remaining seats', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
licenseLimit: { workspace_members: { size: 9, limit: 10 } },
refreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
inviteMember.mockResolvedValue({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
fillEmails('user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
expect(counter.closest('div')).toHaveClass('text-text-destructive')
})
it('should not submit if already submitting', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
renderModal()
fillEmails('user@example.com')
await addRecipients(user, 'one@example.com, two@example.com')
await selectAdminRole(user)
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(screen.getByText(/members\.recipientCountExceedsSeats/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
// First click
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Second click while submitting.
// userEvent will skip this click because the button is disabled.
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Resolve first
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => expect(inviteMember).toHaveBeenCalled())
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
renderModal()
fillEmails('user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(sendBtn).toBeDisabled()
expect(inviteMember).not.toHaveBeenCalled()
})
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
it('counts a manually typed recipient list before it is committed', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 9, limit: 10 } },
refreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
renderModal()
fillEmails('user@example.com')
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'one@example.com,two@example.com')
expect(
screen.getByRole('button', { name: /members\.sendInviteCount.*"count":2/i }),
).toBeEnabled()
expect(screen.getByText(/members\.recipientCountExceedsSeats/i)).toBeInTheDocument()
expect(
screen.queryByRole('list', { name: /members\.emailRecipients/i }),
).not.toBeInTheDocument()
expect(input).toHaveValue('one@example.com,two@example.com')
})
it.each([
['limit_exceeded', /members\.inviteLimitExceeded/i, 'emails', 'textbox'],
['invalid-role', /members\.invalidRole/i, 'role', 'combobox'],
])('maps %s server validation to the owning field', async (code, message, fieldName, role) => {
const user = userEvent.setup()
inviteMember.mockRejectedValue({
code: 'BAD_REQUEST',
data: { body: { code, message: 'Backend message' } },
})
renderModal()
await addRecipients(user, 'user@example.com, another@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
expect(await screen.findByText(message)).toBeInTheDocument()
expect(document.querySelector(`[name="${fieldName}"]`)).toHaveAttribute('aria-invalid', 'true')
expect(
screen.getByRole(role, {
name: fieldName === 'emails' ? /members\.emailRecipients/i : /members\.role/i,
}),
).toHaveFocus()
expect(onOpenChange).not.toHaveBeenCalled()
})
// First click starts submission
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
it('should clear an email server error when the user edits and successfully retries', async () => {
const user = userEvent.setup()
inviteMember
.mockRejectedValueOnce({
code: 'BAD_REQUEST',
data: { body: { code: 'limit_exceeded', message: 'Backend message' } },
})
.mockResolvedValueOnce({
result: 'success',
invitation_results: [],
tenant_id: 'tenant-id',
} satisfies MemberInviteResponse)
renderModal()
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
fireEvent.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
await addRecipients(user, 'first@example.com, second@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(await screen.findByText(/members\.inviteLimitExceeded/i)).toBeInTheDocument()
const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i })
await user.type(input, 'third@example.com')
expect(screen.queryByText(/members\.inviteLimitExceeded/i)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
// Cleanup
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
expect(inviteMember).toHaveBeenCalledTimes(2)
expect(inviteMember.mock.calls[1]?.[0]).toEqual({
body: {
emails: ['first@example.com', 'second@example.com', 'third@example.com'],
role: 'admin',
language: 'en-US',
},
})
})
})
it('should not show error text color when isLimited is false even with many emails', async () => {
// size=0, limit=0 → isLimited=false, usedSize=emails.length
vi.mocked(useProviderContextSelector).mockImplementation((selector) =>
selector({
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]),
)
it('keeps unknown request failures as a persistent form error', async () => {
const user = userEvent.setup()
inviteMember.mockRejectedValue(new Error('Network failed'))
renderModal()
fillEmails('user@example.com')
await addRecipients(user, 'user@example.com, another@example.com')
await selectAdminRole(user)
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
// isLimited=false → no destructive color
const counter = screen.getByText('1')
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
expect(await screen.findByRole('alert')).toHaveTextContent(/members\.inviteFailed/i)
expect(onOpenChange).not.toHaveBeenCalled()
})
it('routes close actions through the controlled state owner', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByRole('button', { name: /operation\.close/i }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('resets the form after a controlled close', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const ControlledInviteModal = () => {
const [open, setOpen] = useState(false)
return (
<QueryClientProvider client={queryClient}>
<InviteModal
open={open}
trigger={<button type="button">members.invite</button>}
isEmailSetup
onOpenChange={setOpen}
onSend={onSend}
/>
</QueryClientProvider>
)
}
render(<ControlledInviteModal />)
const trigger = screen.getByRole('button', { name: /members\.invite$/i })
await user.click(trigger)
await addRecipients(user, 'person@example.com, another@example.com')
await user.click(screen.getByRole('button', { name: /operation\.close/i }))
await waitFor(() => expect(trigger).toHaveFocus())
await user.click(trigger)
expect(screen.queryByText('person@example.com')).not.toBeInTheDocument()
expect(screen.getByRole('combobox', { name: /members\.role/i })).toHaveTextContent(
/members\.selectRole/i,
)
})
})

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import RoleSelector from '../role-selector'
import { RoleSelector } from '../role-selector'
vi.mock('@/service/access-control/use-workspace-roles')
@ -21,59 +21,50 @@ const createRole = (overrides: Partial<Role>): Role => ({
...overrides,
})
const rolePages: RoleListResponse[] = [
{
data: [
createRole({ id: 'admin', name: 'Admin', description: 'Can manage workspace settings' }),
createRole({ id: 'editor', name: 'Editor', description: 'Can build and edit apps' }),
createRole({ id: 'normal', name: 'Normal', description: 'Can use apps' }),
],
pagination: {
total_count: 3,
per_page: 20,
current_page: 1,
total_pages: 1,
},
},
const roles = [
createRole({ id: 'admin', name: 'Admin', description: 'Can manage workspace settings' }),
createRole({ id: 'editor', name: 'Editor', description: 'Can build and edit apps' }),
createRole({ id: 'normal', name: 'Normal', description: 'Can use apps' }),
]
const createPage = (data: Role[]): RoleListResponse => ({
data,
pagination: {
total_count: data.length,
per_page: 20,
current_page: 1,
total_pages: data.length > 0 ? 1 : 0,
},
})
const mockUseWorkspaceRoleList = ({
pages = rolePages,
data = roles,
isLoading = false,
hasNextPage = false,
isFetchingNextPage = false,
fetchNextPage = vi.fn(),
}: {
pages?: RoleListResponse[]
data?: Role[]
isLoading?: boolean
hasNextPage?: boolean
isFetchingNextPage?: boolean
fetchNextPage?: () => void
} = {}) => {
vi.mocked(useWorkspaceRoleList).mockReturnValue({
data: { pages, pageParams: [1] },
data: { pages: [createPage(data)], pageParams: [1] },
isLoading,
error: null,
hasNextPage,
isFetchingNextPage,
isFetchingNextPage: false,
fetchNextPage,
} as unknown as ReturnType<typeof useWorkspaceRoleList>)
}
type WrapperProps = {
initialRole?: string
}
const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => {
const [role, setRole] = useState(initialRole)
const RoleSelectorWrapper = ({ initialRole = null }: { initialRole?: Role | null }) => {
const [role, setRole] = useState<Role | null>(initialRole)
return <RoleSelector value={role} onChange={setRole} />
}
const getTrigger = () =>
screen.getByRole('button', { name: /members\.(invitedAsRole|selectRole)/i })
const getRoleMenu = () => screen.getByRole('menu')
const getRoleOption = (role: string) =>
within(getRoleMenu()).getByRole('menuitemradio', { name: new RegExp(role, 'i') })
const getTrigger = () => screen.getByRole('combobox', { name: /members\.role/i })
const getListbox = () => screen.getByRole('listbox', { name: /members\.role/i })
describe('RoleSelector', () => {
beforeEach(() => {
@ -81,92 +72,65 @@ describe('RoleSelector', () => {
mockUseWorkspaceRoleList()
})
it('should show current role name in trigger text', () => {
render(<RoleSelectorWrapper initialRole="admin" />)
it('requires an explicit selection and exposes select semantics', () => {
render(<RoleSelectorWrapper />)
expect(screen.getByText(/members\.invitedAsRole:\{"role":"Admin"\}/i)).toBeInTheDocument()
expect(getTrigger()).toHaveTextContent(/members\.selectRole/i)
expect(document.querySelector('input[name="role"]')).toBeRequired()
})
it('should ask users to select a role when no role is selected', () => {
render(<RoleSelectorWrapper initialRole="" />)
expect(screen.getByText(/members\.selectRole/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.invitedAsRole:\{"role":""\}/i)).not.toBeInTheDocument()
})
it('should toggle dropdown when trigger is clicked', async () => {
it('uses the selected Role object as its typed value', async () => {
const user = userEvent.setup()
render(<RoleSelectorWrapper />)
await user.click(getTrigger())
expect(getRoleOption('Normal')).toBeInTheDocument()
await user.click(within(getListbox()).getByRole('option', { name: /Admin/i }))
await user.click(getTrigger())
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(getTrigger()).toHaveTextContent('Admin')
expect(document.querySelector('input[name="role"]')).toHaveValue('admin')
})
it('should show checkmark state for selected role', async () => {
it('shows role descriptions in the option list', async () => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="editor" />)
render(<RoleSelectorWrapper />)
await user.click(getTrigger())
expect(getRoleOption('Editor')).toHaveAttribute('aria-checked', 'true')
expect(within(getListbox()).getByText('Can manage workspace settings')).toBeInTheDocument()
})
it('should show legacy descriptions for built-in roles without descriptions', async () => {
it('falls back to localized descriptions for legacy built-in roles', async () => {
const user = userEvent.setup()
mockUseWorkspaceRoleList({
pages: [
{
data: [
createRole({ id: 'admin', name: 'admin', description: '' }),
createRole({ id: 'editor', name: 'editor', description: '' }),
createRole({ id: 'normal', name: 'normal', description: '' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }),
],
pagination: {
total_count: 4,
per_page: 20,
current_page: 1,
total_pages: 1,
},
},
data: [
createRole({ id: 'admin', name: 'admin', description: '' }),
createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }),
],
})
render(<RoleSelectorWrapper initialRole="" />)
render(<RoleSelectorWrapper />)
await user.click(getTrigger())
const roleMenu = getRoleMenu()
expect(within(roleMenu).getByText(/common\.members\.adminTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.editorTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.normalTip/i)).toBeInTheDocument()
expect(within(roleMenu).getByText(/common\.members\.datasetOperatorTip/i)).toBeInTheDocument()
expect(within(roleMenu).queryByText(/permission\.role\.noDescription/i)).not.toBeInTheDocument()
expect(within(getListbox()).getByText(/common\.members\.adminTip/i)).toBeInTheDocument()
expect(
within(getListbox()).getByText(/common\.members\.datasetOperatorTip/i),
).toBeInTheDocument()
})
it('should update selected role name after user chooses a role', async () => {
it('renders loading and empty states inside the select popup', async () => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="Normal" />)
mockUseWorkspaceRoleList({ data: [], isLoading: true })
const { rerender } = render(<RoleSelectorWrapper />)
await user.click(getTrigger())
await user.click(getRoleOption('Admin'))
expect(within(getListbox()).getByText(/common\.loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(screen.getByText(/members\.invitedAsRole:\{"role":"Admin"\}/i)).toBeInTheDocument()
mockUseWorkspaceRoleList({ data: [] })
rerender(<RoleSelectorWrapper />)
expect(within(getListbox()).getByText(/dynamicSelect\.noData/i)).toBeInTheDocument()
})
it('should load more roles when scrolling reaches the list anchor', async () => {
it('loads another role page when the list sentinel becomes visible', async () => {
const user = userEvent.setup()
const fetchNextPage = vi.fn()
const callbacks: IntersectionObserverCallback[] = []
@ -174,36 +138,27 @@ describe('RoleSelector', () => {
globalThis.IntersectionObserver = class {
readonly root: Element | Document | null = null
readonly rootMargin: string = ''
readonly scrollMargin: string = ''
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
constructor(callback: IntersectionObserverCallback) {
callbacks.push(callback)
}
observe() {
/* noop */
}
unobserve() {
/* noop */
}
disconnect() {
/* noop */
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
mockUseWorkspaceRoleList({ hasNextPage: true, fetchNextPage })
render(<RoleSelectorWrapper />)
await user.click(getTrigger())
await waitFor(() => {
expect(callbacks).toHaveLength(1)
})
await waitFor(() => expect(callbacks).toHaveLength(1))
await act(async () => {
callbacks[0]!(
@ -213,30 +168,6 @@ describe('RoleSelector', () => {
})
expect(fetchNextPage).toHaveBeenCalledTimes(1)
globalThis.IntersectionObserver = originalIntersectionObserver
})
it('should render an empty state when there are no roles', async () => {
const user = userEvent.setup()
mockUseWorkspaceRoleList({
pages: [
{
data: [],
pagination: {
total_count: 0,
per_page: 20,
current_page: 1,
total_pages: 0,
},
},
],
})
render(<RoleSelectorWrapper initialRole="" />)
await user.click(getTrigger())
expect(within(getRoleMenu()).getByText(/dynamicSelect\.noData/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,331 @@
'use client'
import type { KeyboardEvent, RefObject } from 'react'
import type { EmailRecipient } from './email-recipients'
import { cn } from '@langgenius/dify-ui/cn'
import {
Field,
FieldControl,
FieldDescription,
FieldError,
FieldLabel,
} from '@langgenius/dify-ui/field'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { hasEmailDelimiter, mergeEmailRecipients } from './email-recipients'
type EmailRecipientsFieldProps = {
recipients: EmailRecipient[]
draft: string
onRecipientsChange: (recipients: EmailRecipient[]) => void
onDraftChange: (draft: string) => void
onChange?: () => void
error?: string
disabled?: boolean
inputRef?: RefObject<HTMLInputElement | null>
}
function isRightToLeft(element: HTMLElement) {
return getComputedStyle(element).direction === 'rtl' || document.documentElement.dir === 'rtl'
}
export function EmailRecipientsField({
recipients,
draft,
onRecipientsChange,
onDraftChange,
onChange,
error,
disabled = false,
inputRef: externalInputRef,
}: EmailRecipientsFieldProps) {
const { t } = useTranslation()
const internalInputRef = useRef<HTMLInputElement>(null)
const chipButtonRef = useRef<Array<HTMLButtonElement | null>>([])
const selectDraftOnRenderRef = useRef(false)
const [draftTouched, setDraftTouched] = useState(false)
const inputRef = externalInputRef ?? internalInputRef
const hasInvalidRecipient = recipients.some(({ isValid }) => !isValid)
const draftRecipients = mergeEmailRecipients([], draft)
const hasInvalidDraft = Boolean(
draftTouched && draft.trim() && draftRecipients.some(({ isValid }) => !isValid),
)
const fieldError =
hasInvalidRecipient || hasInvalidDraft
? t(($) => $['members.emailInvalid'], { ns: 'common' })
: error
const validateRecipients = (value: unknown) => {
const nextDraft = typeof value === 'string' ? value : draft
const nextRecipients = mergeEmailRecipients(recipients, nextDraft)
if (nextRecipients.length === 0) return t(($) => $['members.emailRequired'], { ns: 'common' })
if (nextRecipients.some(({ isValid }) => !isValid))
return t(($) => $['members.emailInvalid'], { ns: 'common' })
return null
}
const updateRecipients = (nextRecipients: EmailRecipient[]) => {
onRecipientsChange(nextRecipients)
onChange?.()
}
const updateDraft = (nextDraft: string) => {
onDraftChange(nextDraft)
onChange?.()
}
const focusInput = (select = false) => {
inputRef.current?.focus()
if (select) {
selectDraftOnRenderRef.current = true
inputRef.current?.select()
}
}
useEffect(() => {
if (!selectDraftOnRenderRef.current) return
inputRef.current?.select()
selectDraftOnRenderRef.current = false
}, [draft, inputRef])
const removeRecipient = (index: number, focus: 'input' | 'neighbor') => {
const nextRecipients = recipients.filter((_, recipientIndex) => recipientIndex !== index)
const nextFocusIndex = index < nextRecipients.length ? index : index - 1
const neighborIndex = index < recipients.length - 1 ? index + 1 : index - 1
const neighbor = neighborIndex >= 0 ? chipButtonRef.current[neighborIndex] : null
updateRecipients(nextRecipients)
if (focus === 'neighbor' && nextFocusIndex >= 0) {
neighbor?.focus()
return
}
focusInput()
}
const editRecipient = (index: number) => {
const recipient = recipients[index]
if (!recipient) return
updateRecipients(recipients.filter((_, recipientIndex) => recipientIndex !== index))
updateDraft(recipient.value)
setDraftTouched(false)
focusInput(true)
}
const commitDraft = () => {
if (!draft.trim()) return
if (draftRecipients.some(({ isValid }) => !isValid)) {
setDraftTouched(true)
return
}
updateRecipients(mergeEmailRecipients(recipients, draft))
updateDraft('')
setDraftTouched(false)
}
const handleChipKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
index: number,
isValid: boolean,
) => {
if (disabled) return
if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault()
removeRecipient(index, 'neighbor')
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (isValid) focusInput()
else editRecipient(index)
return
}
const rtl = isRightToLeft(event.currentTarget)
const previousKey = rtl ? 'ArrowRight' : 'ArrowLeft'
const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight'
if (event.key === previousKey && index > 0) {
event.preventDefault()
chipButtonRef.current[index - 1]?.focus()
} else if (event.key === nextKey) {
event.preventDefault()
if (index < recipients.length - 1) chipButtonRef.current[index + 1]?.focus()
else focusInput()
}
}
return (
<Field name="emails" invalid={Boolean(fieldError)} validate={validateRecipients}>
<div className="flex items-center justify-between gap-3">
<FieldLabel>{t(($) => $['members.emailRecipients'], { ns: 'common' })}</FieldLabel>
{recipients.length > 0 && (
<span aria-live="polite" className="py-1 body-xs-regular text-text-tertiary tabular-nums">
{t(($) => $['members.recipientCount'], {
ns: 'common',
count: recipients.length,
})}
</span>
)}
</div>
<div
className={cn(
'flex max-h-24 min-h-10 flex-wrap content-start items-center gap-1 overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1.5 transition-[background-color,border-color,box-shadow]',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
'group-data-invalid/field:border-components-input-border-destructive group-data-invalid/field:bg-components-input-bg-destructive',
disabled &&
'cursor-not-allowed bg-components-input-bg-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
)}
>
{recipients.length > 0 && (
<ul
aria-label={t(($) => $['members.emailRecipients'], { ns: 'common' })}
className="contents"
>
{recipients.map(({ value, isValid }, index) => {
const errorId = `email-recipient-${encodeURIComponent(value)}-error`
return (
<li
key={value}
className={cn(
'flex h-6 max-w-full items-center gap-1 rounded-md bg-components-badge-bg-gray-soft px-2 system-xs-medium text-text-secondary outline-hidden',
'focus-within:inset-ring-1 focus-within:inset-ring-components-input-border-active',
!isValid && 'bg-components-badge-bg-red-soft text-text-destructive',
)}
>
<button
ref={(node) => {
chipButtonRef.current[index] = node
}}
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={
isValid
? value
: `${t(($) => $['operation.edit'], { ns: 'common' })} ${value}`
}
aria-describedby={!isValid ? errorId : undefined}
className="flex min-w-0 items-center gap-1 truncate text-start outline-hidden"
onKeyDown={(event) => handleChipKeyDown(event, index, isValid)}
onClick={() => {
if (isValid) focusInput()
else editRecipient(index)
}}
>
{!isValid && (
<span
aria-hidden="true"
className="i-ri-error-warning-fill size-3.5 shrink-0"
/>
)}
<span className="truncate">{value}</span>
</button>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={`${t(($) => $['operation.remove'], { ns: 'common' })} ${value}`}
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
onMouseDown={(event) => event.preventDefault()}
onClick={() => removeRecipient(index, 'input')}
>
<span aria-hidden="true" className="i-ri-close-line size-3.5" />
</button>
{!isValid && (
<span id={errorId} className="sr-only">
{t(($) => $['members.emailInvalid'], { ns: 'common' })}
</span>
)}
</li>
)
})}
</ul>
)}
<FieldControl
ref={inputRef}
type="text"
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="email"
placeholder={
recipients.length === 0
? t(($) => $['members.emailPlaceholder'], { ns: 'common' }) || ''
: ''
}
value={draft}
className={cn(
'h-6 w-0 flex-1 rounded-none border-0 bg-transparent p-0 shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent focus:shadow-none disabled:bg-transparent disabled:hover:bg-transparent data-invalid:border-transparent data-invalid:bg-transparent',
recipients.length > 0 ? 'min-w-12' : 'min-w-40',
)}
onChange={(event) => {
updateDraft(event.target.value)
setDraftTouched(false)
}}
onBlur={() => setDraftTouched(true)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) {
if (event.key === 'Enter') event.preventDefault()
return
}
if (event.key === 'Enter' && draft.trim()) {
event.preventDefault()
commitDraft()
return
}
if (event.key === 'Backspace' && !draft && recipients.length > 0) {
event.preventDefault()
updateRecipients(recipients.slice(0, -1))
return
}
const rtl = isRightToLeft(event.currentTarget)
const chipNavigationKey = rtl ? 'ArrowRight' : 'ArrowLeft'
if (event.key === chipNavigationKey && !draft && recipients.length > 0) {
event.preventDefault()
chipButtonRef.current[recipients.length - 1]?.focus()
}
}}
onPaste={(event) => {
const pastedText = event.clipboardData.getData('text')
if (!hasEmailDelimiter(pastedText)) return
event.preventDefault()
const input = event.currentTarget
const selectionStart = input.selectionStart ?? input.value.length
const selectionEnd = input.selectionEnd ?? selectionStart
const nextValue = `${input.value.slice(0, selectionStart)}${pastedText}${input.value.slice(selectionEnd)}`
updateRecipients(mergeEmailRecipients(recipients, nextValue))
updateDraft('')
setDraftTouched(false)
}}
/>
</div>
{fieldError ? (
<FieldError match>{fieldError}</FieldError>
) : (
<>
<FieldError match="customError" />
<FieldDescription className="group-data-invalid/field:hidden">
{t(($) => $['members.emailRecipientsTip'], { ns: 'common' })}
</FieldDescription>
</>
)}
</Field>
)
}

View File

@ -0,0 +1,41 @@
export type EmailRecipient = {
value: string
isValid: boolean
}
const EMAIL_DELIMITER_PATTERN = /[,;\r\n\t]+/
function isEmailValid(value: string) {
const input = document.createElement('input')
input.type = 'email'
input.value = value
return input.validity.valid
}
export function createEmailRecipient(input: string): EmailRecipient {
const value = input.trim().toLowerCase()
return { value, isValid: Boolean(value) && isEmailValid(value) }
}
export function hasEmailDelimiter(value: string) {
return EMAIL_DELIMITER_PATTERN.test(value)
}
export function mergeEmailRecipients(recipients: EmailRecipient[], input: string) {
const nextRecipients = [...recipients]
const existingValues = new Set(recipients.map(({ value }) => value))
input
.split(EMAIL_DELIMITER_PATTERN)
.map(createEmailRecipient)
.filter(({ value }) => Boolean(value))
.forEach((recipient) => {
const { value } = recipient
if (existingValues.has(value)) return
existingValues.add(value)
nextRecipients.push(recipient)
})
return nextRecipients
}

View File

@ -1,184 +1,231 @@
'use client'
import type { InvitationResult } from '@/models/common'
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FormProps } from '@langgenius/dify-ui/form'
import type { ReactElement } from 'react'
import type { EmailRecipient } from './email-recipients'
import type { Role } from '@/models/access-control'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@langgenius/dify-ui/dialog'
import { Form } from '@langgenius/dify-ui/form'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactMultiEmail } from 'react-multi-email'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import { consoleQuery } from '@/service/client'
import { commonQueryKeys } from '@/service/use-common'
import RoleSelector from './role-selector'
import 'react-multi-email/dist/style.css'
import { mergeEmailRecipients } from './email-recipients'
import { EmailRecipientsField } from './email-recipients-field'
import { getInviteErrorCode } from './invite-error'
import { RoleSelector } from './role-selector'
type IInviteModalProps = {
type InviteModalProps = {
open: boolean
trigger: ReactElement
isEmailSetup: boolean
onCancel: () => void
onSend: (invitationResults: InvitationResult[]) => void
onOpenChange: (open: boolean) => void
onSend: (invitationResults: MemberInviteResponse['invitation_results']) => void
}
const InviteModal = ({ isEmailSetup, onCancel, onSend }: IInviteModalProps) => {
type SubmitError = {
target: 'emails' | 'role' | 'form'
message: string
} | null
type FormSubmitHandler = NonNullable<FormProps['onSubmit']>
type InviteFormProps = Omit<InviteModalProps, 'open' | 'trigger'>
function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const licenseLimit = useProviderContextSelector((s) => s.licenseLimit)
const refreshLicenseLimit = useProviderContextSelector((s) => s.refreshLicenseLimit)
const [emails, setEmails] = useState<string[]>([])
const isLimited = licenseLimit.workspace_members.limit > 0
const usedSize = emails.length + licenseLimit.workspace_members.size
const isLimitExceeded = isLimited && usedSize > licenseLimit.workspace_members.limit
const locale = useLocale()
const [role, setRole] = useState<string>('')
const queryClient = useQueryClient()
const licenseLimit = useProviderContextSelector((state) => state.licenseLimit)
const refreshLicenseLimit = useProviderContextSelector((state) => state.refreshLicenseLimit)
const [recipients, setRecipients] = useState<EmailRecipient[]>([])
const [draft, setDraft] = useState('')
const [role, setRole] = useState<Role | null>(null)
const [submitError, setSubmitError] = useState<SubmitError>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const roleTriggerRef = useRef<HTMLButtonElement>(null)
const emailServerError = submitError?.target === 'emails' ? submitError.message : undefined
const roleError = submitError?.target === 'role' ? submitError.message : undefined
const currentSize = licenseLimit.workspace_members.size ?? 0
const memberLimit = licenseLimit.workspace_members.limit
const remainingSeats = memberLimit > 0 ? Math.max(memberLimit - currentSize, 0) : null
const effectiveRecipients = mergeEmailRecipients(recipients, draft)
const validRecipientCount = effectiveRecipients.filter(({ isValid }) => isValid).length
const exceedsRemainingSeats = remainingSeats !== null && validRecipientCount > remainingSeats
const formErrors = {
...(emailServerError ? { emails: emailServerError } : {}),
...(roleError ? { role: roleError } : {}),
}
const [isSubmitting, { setTrue: setIsSubmitting, setFalse: setIsSubmitted }] = useBoolean(false)
const { mutateAsync, isPending } = useMutation(
consoleQuery.workspaces.current.members.inviteEmail.post.mutationOptions({
context: { silent: true },
}),
)
const handleSend = useCallback(async () => {
if (isLimitExceeded || isSubmitting) return
setIsSubmitting()
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
try {
const { result, invitation_results } = await inviteMember({
url: '/workspaces/current/members/invite-email',
body: { emails, role, language: locale },
})
const clearSubmitError = (target: 'emails' | 'role') => {
setSubmitError((error) => (error?.target === target ? null : error))
}
if (result === 'success') {
refreshLicenseLimit()
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
onCancel()
onSend(invitation_results)
}
} catch {}
} else {
toast.error(t(($) => $['members.emailInvalid'], { ns: 'common' }))
useEffect(() => {
if (submitError?.target === 'emails') emailInputRef.current?.focus()
if (submitError?.target === 'role') roleTriggerRef.current?.focus()
}, [submitError])
const handleSubmit: FormSubmitHandler = async (event) => {
event.preventDefault()
if (isPending) return
setRecipients(effectiveRecipients)
setDraft('')
if (!role) return
setSubmitError(null)
try {
const response = await mutateAsync({
body: {
emails: effectiveRecipients.map(({ value }) => value),
role: role.id,
language: locale,
},
})
refreshLicenseLimit()
void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members })
onOpenChange(false)
onSend(response.invitation_results)
} catch (error) {
switch (getInviteErrorCode(error)) {
case 'limit_exceeded':
setSubmitError({
target: 'emails',
message: t(($) => $['members.inviteLimitExceeded'], { ns: 'common' }),
})
break
case 'invalid-role':
setSubmitError({
target: 'role',
message: t(($) => $['members.invalidRole'], { ns: 'common' }),
})
break
default:
setSubmitError({
target: 'form',
message: t(($) => $['members.inviteFailed'], { ns: 'common' }),
})
}
}
setIsSubmitted()
}, [
isLimitExceeded,
emails,
role,
locale,
onCancel,
onSend,
t,
isSubmitting,
refreshLicenseLimit,
queryClient,
setIsSubmitted,
setIsSubmitting,
])
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel()
}}
<Form
aria-label={t(($) => $['members.inviteTeamMember'], { ns: 'common' })}
errors={formErrors}
className="grid gap-5 pt-5"
onSubmit={handleSubmit}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-100 overflow-visible px-8 py-6"
{!isEmailSetup && (
<div className="flex items-start gap-1.5 rounded-lg bg-state-warning-hover p-2 text-text-warning">
<span aria-hidden="true" className="i-ri-error-warning-fill size-4 shrink-0" />
<span className="system-xs-medium text-text-primary">
{t(($) => $['members.emailNotSetup'], { ns: 'common' })}
</span>
</div>
)}
<EmailRecipientsField
recipients={recipients}
draft={draft}
onRecipientsChange={setRecipients}
onDraftChange={setDraft}
onChange={() => clearSubmitError('emails')}
error={emailServerError}
disabled={isPending}
inputRef={emailInputRef}
/>
<RoleSelector
value={role}
onChange={setRole}
onInteract={() => clearSubmitError('role')}
error={roleError}
disabled={isPending}
triggerRef={roleTriggerRef}
/>
{exceedsRemainingSeats && (
<div
role="status"
className="flex items-start gap-1.5 rounded-lg bg-state-warning-hover p-2 body-xs-regular text-text-warning"
>
<span aria-hidden="true" className="i-ri-error-warning-line size-4 shrink-0" />
<span>
{t(($) => $['members.seatsRemaining'], {
ns: 'common',
count: remainingSeats,
})}
<span aria-hidden="true"> · </span>
{t(($) => $['members.recipientCountExceedsSeats'], { ns: 'common' })}
</span>
</div>
)}
{submitError?.target === 'form' && (
<div role="alert" className="body-xs-regular text-text-destructive">
{submitError.message}
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
loading={isPending}
disabled={isPending}
>
<DialogCloseButton className="top-6 right-8" />
<div className="mb-2 pr-8">
{validRecipientCount > 0
? t(($) => $['members.sendInviteCount'], {
ns: 'common',
count: validRecipientCount,
})
: t(($) => $['members.sendInvite'], { ns: 'common' })}
</Button>
</Form>
)
}
export function InviteModal({
open,
trigger,
isEmailSetup,
onOpenChange,
onSend,
}: InviteModalProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={(nextOpen) => onOpenChange(nextOpen)}>
<DialogTrigger render={trigger} />
<DialogContent backdropProps={{ forceRender: true }}>
<div className="grid gap-1 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary">
{t(($) => $['members.inviteTeamMember'], { ns: 'common' })}
</DialogTitle>
<DialogDescription className="text-sm text-text-tertiary">
{t(($) => $['members.inviteTeamMemberTip'], { ns: 'common' })}
</DialogDescription>
</div>
<div className="mb-3 text-[13px] text-text-tertiary">
{t(($) => $['members.inviteTeamMemberTip'], { ns: 'common' })}
</div>
{!isEmailSetup && (
<div className="grow basis-0 overflow-y-auto pb-4">
<div className="relative mb-1 rounded-xl border border-components-panel-border p-2 shadow-xs">
<div
className="absolute top-0 left-0 size-full rounded-xl opacity-40"
style={{
background:
'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)',
}}
></div>
<div className="relative flex size-full items-start">
<div className="mr-0.5 shrink-0 p-0.5">
<div className="i-ri-error-warning-fill size-5 text-text-warning" />
</div>
<div className="system-xs-medium text-text-primary">
<span>{t(($) => $['members.emailNotSetup'], { ns: 'common' })}</span>
</div>
</div>
</div>
</div>
)}
<div>
<div className="mb-2 text-sm font-medium text-text-primary">
{t(($) => $['members.email'], { ns: 'common' })}
</div>
<div className="mb-8 flex h-36 flex-col items-stretch">
<ReactMultiEmail
className={cn(
'size-full border-components-input-border-active bg-components-input-bg-normal! px-3 pt-2 outline-hidden',
'appearance-none overflow-y-auto rounded-lg text-sm text-text-primary!',
)}
autoFocus
emails={emails}
inputClassName="bg-transparent"
onChange={setEmails}
getLabel={(email, index, removeEmail) => (
<div data-tag key={index} className={cn('bg-components-button-secondary-bg!')}>
<div data-tag-item>{email}</div>
<button
type="button"
data-tag-handle
aria-label={`${t(($) => $['operation.remove'], { ns: 'common' })} ${email}`}
className="border-none bg-transparent p-0 text-inherit"
onClick={() => removeEmail(index)}
>
×
</button>
</div>
)}
placeholder={t(($) => $['members.emailPlaceholder'], { ns: 'common' }) || ''}
/>
<div
className={cn(
'flex items-center justify-end system-xs-regular text-text-tertiary',
isLimited && usedSize > licenseLimit.workspace_members.limit
? 'text-text-destructive'
: '',
)}
>
<span>{usedSize}</span>
<span>/</span>
<span>
{isLimited
? licenseLimit.workspace_members.limit
: t(($) => $['license.unlimited'], { ns: 'common' })}
</span>
</div>
</div>
<div className="mb-6">
<RoleSelector value={role} onChange={setRole} />
</div>
<Button
tabIndex={0}
className="w-full"
onClick={handleSend}
disabled={!emails.length || !role || isLimitExceeded || isSubmitting}
variant="primary"
>
{t(($) => $['members.sendInvite'], { ns: 'common' })}
</Button>
</div>
<InviteForm isEmailSetup={isEmailSetup} onOpenChange={onOpenChange} onSend={onSend} />
<DialogCloseButton aria-label={t(($) => $['operation.close'], { ns: 'common' })} />
</DialogContent>
</Dialog>
)
}
export default InviteModal

View File

@ -0,0 +1,12 @@
function getRecord(value: unknown) {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
export function getInviteErrorCode(error: unknown) {
const errorRecord = getRecord(error)
const dataRecord = getRecord(errorRecord?.data)
const bodyRecord = getRecord(dataRecord?.body)
const code = bodyRecord?.code ?? errorRecord?.code
return typeof code === 'string' ? code : null
}

View File

@ -1,22 +1,31 @@
'use client'
import type { Ref } from 'react'
import type { Role } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { Field, FieldError } from '@langgenius/dify-ui/field'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
type RoleSelectorProps = {
value: string
onChange: (role: string) => void
value: Role | null
onChange: (role: Role | null) => void
onInteract?: () => void
error?: string
disabled?: boolean
triggerRef?: Ref<HTMLButtonElement>
}
const PAGE_SIZE = 20
@ -29,30 +38,39 @@ const LEGACY_ROLE_DESCRIPTION_KEY_MAP = {
type LegacyRoleKey = keyof typeof LEGACY_ROLE_DESCRIPTION_KEY_MAP
const normalizeLegacyRoleKey = (value: string) => value.trim().toLowerCase()
function normalizeLegacyRoleKey(value: string) {
return value.trim().toLowerCase()
}
const isLegacyRoleKey = (value: string): value is LegacyRoleKey =>
Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value)
function isLegacyRoleKey(value: string): value is LegacyRoleKey {
return Object.prototype.hasOwnProperty.call(LEGACY_ROLE_DESCRIPTION_KEY_MAP, value)
}
const getLegacyRoleDescriptionKey = (role: Role) => {
function getLegacyRoleDescriptionKey(role: Role) {
const candidateKeys = [normalizeLegacyRoleKey(role.name), normalizeLegacyRoleKey(role.id)]
return candidateKeys.find(isLegacyRoleKey)
}
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
export function RoleSelector({
value,
onChange,
onInteract,
error,
disabled = false,
triggerRef,
}: RoleSelectorProps) {
const { t } = useTranslation()
const locale = useLocale()
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const [observerReadyKey, setObserverReadyKey] = useState(0)
const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale])
const language = getAccessControlTemplateLanguage(locale)
const {
data: rolesData,
isLoading: rolesLoading,
error,
error: rolesError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
@ -66,11 +84,11 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
if (error || !open) return
if (rolesError || !open) return
if (anchorRef.current && containerRef.current) {
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
if (anchorRef.current && listRef.current) {
const listHeight = listRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(listHeight * 0.2, 200))
observer = new IntersectionObserver(
(entries) => {
@ -78,13 +96,13 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
entries[0]!.isIntersecting &&
!rolesLoading &&
!isFetchingNextPage &&
!error &&
!rolesError &&
hasMore
)
fetchNextPage()
},
{
root: containerRef.current,
root: listRef.current,
rootMargin: `${dynamicMargin}px`,
},
)
@ -92,17 +110,20 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
}
return () => observer?.disconnect()
}, [rolesLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, open, observerReadyKey])
}, [
rolesLoading,
isFetchingNextPage,
fetchNextPage,
rolesError,
hasNextPage,
open,
observerReadyKey,
])
const roles = useMemo(() => rolesData?.pages.flatMap((page) => page.data) ?? [], [rolesData])
const selectedRole = roles.find((role) => role.id === value)
const selectedRoleName = selectedRole?.name || value
const triggerLabel = selectedRoleName
? t(($) => $['members.invitedAsRole'], { ns: 'common', role: selectedRoleName })
: t(($) => $['members.selectRole'], { ns: 'common' })
const roles = rolesData?.pages.flatMap((page) => page.data) ?? []
const setContainerElement = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node
const setListElement = useCallback((node: HTMLDivElement | null) => {
listRef.current = node
setObserverReadyKey((key) => key + 1)
}, [])
@ -111,17 +132,10 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
setObserverReadyKey((key) => key + 1)
}, [])
const handleRoleChange = (roleId: string) => {
onChange(roleId)
setOpen(false)
}
const getRoleDescription = (role: Role) => {
if (role.description) return role.description
const legacyRoleDescriptionKey = getLegacyRoleDescriptionKey(role)
switch (legacyRoleDescriptionKey) {
switch (getLegacyRoleDescriptionKey(role)) {
case 'admin':
return t(($) => $['members.adminTip'], { ns: 'common' })
case 'editor':
@ -136,74 +150,68 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
}
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger
data-testid="role-selector-trigger"
className={cn(
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}
<Field name="role" invalid={Boolean(error)}>
<Select<Role>
name="role"
required
disabled={disabled}
value={value}
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
onInteract?.()
}}
onValueChange={(nextRole) => {
onChange(nextRole)
onInteract?.()
}}
itemToStringLabel={(role) => role.name}
itemToStringValue={(role) => role.id}
isItemEqualToValue={(role, selectedRole) => role.id === selectedRole.id}
>
<div className="mr-2 grow text-sm/5 text-text-primary">{triggerLabel}</div>
<div
className={cn(
'size-4 shrink-0 text-text-secondary',
open ? 'i-ri-arrow-up-s-line' : 'i-ri-arrow-down-s-line',
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-0 shadow-lg"
>
<ScrollArea
ref={setContainerElement}
slotClassNames={{ viewport: 'overscroll-contain max-h-70' }}
<SelectLabel>{t(($) => $['members.role'], { ns: 'common' })}</SelectLabel>
<SelectTrigger ref={triggerRef}>
<SelectValue placeholder={t(($) => $['members.selectRole'], { ns: 'common' })} />
</SelectTrigger>
<SelectContent
listClassName="max-h-70"
listProps={{
ref: setListElement,
'aria-label': t(($) => $['members.role'], { ns: 'common' }),
}}
>
<div className="p-1">
{rolesLoading ? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t(($) => $.loading, { ns: 'common' })}
</div>
) : roles.length === 0 ? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t(($) => $['dynamicSelect.noData'], { ns: 'common' })}
</div>
) : (
<>
<DropdownMenuRadioGroup value={value} onValueChange={handleRoleChange}>
{roles.map((role) => (
<DropdownMenuRadioItem
key={role.id}
value={role.id}
className="mx-0 h-auto w-full cursor-pointer items-start gap-0 rounded-lg border-none bg-transparent p-2 text-left hover:bg-state-base-hover data-highlighted:bg-state-base-hover"
>
<div className="relative min-w-0 pl-5">
<div className="truncate text-sm leading-5 text-text-secondary">
{role.name}
</div>
<div className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">
{getRoleDescription(role)}
</div>
{value === role.id && (
<div
aria-hidden="true"
className="absolute top-0.5 left-0 i-custom-vender-line-general-check h-4 w-4 text-text-accent"
/>
)}
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<div ref={setAnchorElement} className="h-0" />
</>
)}
</div>
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
{rolesLoading ? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t(($) => $.loading, { ns: 'common' })}
</div>
) : roles.length === 0 ? (
<div className="px-3 py-6 text-center system-sm-regular text-text-tertiary">
{t(($) => $['dynamicSelect.noData'], { ns: 'common' })}
</div>
) : (
<>
{roles.map((role) => (
<SelectItem key={role.id} value={role} className="h-auto items-start py-2">
<SelectItemText className="grid gap-0.5">
<span className="truncate text-sm leading-5 text-text-secondary">
{role.name}
</span>
<span className="line-clamp-2 text-xs leading-4.5 text-text-tertiary">
{getRoleDescription(role)}
</span>
</SelectItemText>
<SelectItemIndicator className="mt-0.5" />
</SelectItem>
))}
<div ref={setAnchorElement} className="h-0" />
</>
)}
</SelectContent>
</Select>
<FieldError match="valueMissing">
{t(($) => $['members.selectRole'], { ns: 'common' })}
</FieldError>
<FieldError match={Boolean(error)}>{error}</FieldError>
</Field>
)
}
export default RoleSelector

View File

@ -1,4 +1,4 @@
import type { InvitationResult } from '@/models/common'
import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { render, screen } from '@testing-library/react'
import InvitedModal from '../index'
@ -12,7 +12,7 @@ vi.mock('@/config', () => ({
describe('InvitedModal', () => {
const mockOnCancel = vi.fn()
const results: InvitationResult[] = [
const results: MemberInviteResponse['invitation_results'] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
{
email: 'member@example.com',
@ -39,7 +39,7 @@ describe('InvitedModal', () => {
})
it('should hide invitation link section when there are no successes', () => {
const failedOnly: InvitationResult[] = [
const failedOnly: MemberInviteResponse['invitation_results'] = [
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
]
@ -50,7 +50,7 @@ describe('InvitedModal', () => {
})
it('should hide failed section when there are only successes', () => {
const successOnly: InvitationResult[] = [
const successOnly: MemberInviteResponse['invitation_results'] = [
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
]
@ -61,8 +61,12 @@ describe('InvitedModal', () => {
})
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' },
const alreadyMembers: MemberInviteResponse['invitation_results'] = [
{
email: 'member@example.com',
status: 'already_member',
message: 'Account already in workspace.',
},
]
render(<InvitedModal invitationResults={alreadyMembers} onCancel={mockOnCancel} />)
@ -94,7 +98,7 @@ describe('InvitedModal (non-CE edition)', () => {
})
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
const results: InvitationResult[] = [
const results: MemberInviteResponse['invitation_results'] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
]
@ -107,7 +111,13 @@ describe('InvitedModal (non-CE edition)', () => {
})
it('should show already-member details when IS_CE_EDITION is false', () => {
const results: InvitationResult[] = [{ email: 'member@example.com', status: 'already_member' }]
const results: MemberInviteResponse['invitation_results'] = [
{
email: 'member@example.com',
status: 'already_member',
message: 'Account already in workspace.',
},
]
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)

View File

@ -1,4 +1,9 @@
import type { InvitationResult } from '@/models/common'
import type {
MemberInviteAlreadyMemberResponse,
MemberInviteFailedResponse,
MemberInviteResponse,
MemberInviteSuccessResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
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'
@ -6,25 +11,21 @@ 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 = {
invitationResults: InvitationResult[]
invitationResults: MemberInviteResponse['invitation_results']
onCancel: () => void
}
const InvitedModal = ({ invitationResults, onCancel }: IInvitedModalProps) => {
const { t } = useTranslation()
const successInvitationResults = invitationResults.filter(
(item): item is SuccessInvitationResult => item.status === 'success',
(item): item is MemberInviteSuccessResponse => item.status === 'success',
)
const alreadyMemberInvitationResults = invitationResults.filter(
(item): item is AlreadyMemberInvitationResult => item.status === 'already_member',
(item): item is MemberInviteAlreadyMemberResponse => item.status === 'already_member',
)
const failedInvitationResults = invitationResults.filter(
(item): item is FailedInvitationResult => item.status === 'failed',
(item): item is MemberInviteFailedResponse => item.status === 'failed',
)
const onlyAlreadyMembers =
alreadyMemberInvitationResults.length > 0 &&

View File

@ -1,5 +1,5 @@
'use client'
import type { SuccessInvitationResult } from '.'
import type { MemberInviteSuccessResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import copy from 'copy-to-clipboard'
import * as React from 'react'
@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import s from './index.module.css'
type IInvitationLinkProps = {
value: SuccessInvitationResult
value: MemberInviteSuccessResponse
}
const InvitationLink = ({ value }: IInvitationLinkProps) => {

View File

@ -120,7 +120,7 @@ const MemberMenu = ({
render={
<ActionButton
size="l"
className="data-popup-open:bg-state-base-hover"
className="focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover"
aria-label={t(($) => $['members.memberActions'], {
ns: 'common',
defaultValue: 'Member actions',
@ -133,7 +133,7 @@ const MemberMenu = ({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="min-w-[180px] rounded-xl p-1"
popupClassName="min-w-[180px] rounded-xl"
>
{canAssignRoles && (
<DropdownMenuItem

View File

@ -89,6 +89,11 @@ type PluralBaseResources = {
billing: {
'plansCommon.teamMember': string
}
common: {
'members.recipientCount': string
'members.seatsRemaining': string
'members.sendInviteCount': string
}
dataset: {
docAllEnabled: string
partialEnabled: string

View File

@ -228,12 +228,18 @@
"members.email": "البريد الإلكتروني",
"members.emailInvalid": "تنسيق البريد الإلكتروني غير صالح",
"members.emailNotSetup": "لم يتم إعداد خادم البريد الإلكتروني، لذا لا يمكن إرسال رسائل بريد إلكتروني للدعوة. يرجى إخطار المستخدمين برابط الدعوة الذي سيتم إصداره بعد الدعوة بدلاً من ذلك.",
"members.emailPlaceholder": "يرجى إدخال رسائل البريد الإلكتروني",
"members.emailPlaceholder": "اكتب عناوين البريد الإلكتروني أو الصقها…",
"members.emailRecipients": "عناوين البريد الإلكتروني",
"members.emailRecipientsTip": "الصق قائمة، أو اضغط على Enter بعد كل عنوان.",
"members.emailRequired": "أدخل عنوان بريد إلكتروني واحدًا على الأقل.",
"members.failedInvitationEmails": "لم تتم دعوة المستخدمين أدناه بنجاح",
"members.invalidRole": "الدور المحدد لم يعد متاحًا. اختر دورًا آخر.",
"members.invitationLink": "رابط الدعوة",
"members.invitationSent": "تم إرسال الدعوة",
"members.invitationSentTip": "تم إرسال الدعوة، ويمكنهم تسجيل الدخول إلى Dify للوصول إلى بيانات فريقك.",
"members.invite": "إضافة",
"members.inviteFailed": "تعذر إرسال الدعوات. يُرجى المحاولة مرة أخرى.",
"members.inviteLimitExceeded": "لا تتوفر مقاعد كافية لجميع المستلمين.",
"members.inviteTeamMember": "إضافة عضو فريق",
"members.inviteTeamMemberTip": "يمكنهم الوصول إلى بيانات فريقك مباشرة بعد تسجيل الدخول.",
"members.invitedAsRole": "تمت الدعوة كـ {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "يمكنه استخدام التطبيقات فقط، ولا يمكنه بناء التطبيقات",
"members.ok": "موافق",
"members.pending": "قيد الانتظار...",
"members.recipientCountExceedsSeats": "لا يزال بإمكانك إرسال الدعوات. لن يشغل الأعضاء الحاليون مقعدًا إضافيًا.",
"members.recipientCount_one": "{{count}} مستلم",
"members.recipientCount_other": "{{count}} مستلم",
"members.removeFromTeam": "إزالة من الفريق",
"members.removeFromTeamConfirmDescription": "يرجى تأكيد إزالة هذا العضو. لا يمكن التراجع عن هذا الإجراء.",
"members.removeFromTeamConfirmTitle": "إزالة {{memberName}} من الفريق",
"members.role": "الدور",
"members.roles": "الأدوار",
"members.seatsRemaining_one": "{{count}} مقعد متبقٍ",
"members.seatsRemaining_other": "{{count}} مقعد متبقٍ",
"members.selectRole": "اختر دورًا",
"members.sendInvite": "إرسال دعوة",
"members.sendInviteCount_one": "إرسال {{count}} دعوة",
"members.sendInviteCount_other": "إرسال {{count}} دعوة",
"members.transferModal.codeLabel": "رمز التحقق",
"members.transferModal.codePlaceholder": "الصق الرمز المكون من 6 أرقام",
"members.transferModal.continue": "متابعة",

View File

@ -228,12 +228,18 @@
"members.email": "E-Mail",
"members.emailInvalid": "Ungültiges E-Mail-Format",
"members.emailNotSetup": "E-Mail-Server ist nicht eingerichtet, daher können keine Einladungs-E-Mails versendet werden. Bitte informieren Sie die Benutzer über den Einladungslink, der nach der Einladung ausgestellt wird.",
"members.emailPlaceholder": "Bitte E-Mails eingeben",
"members.emailPlaceholder": "E-Mail-Adressen eingeben oder einfügen…",
"members.emailRecipients": "E-Mail-Adressen",
"members.emailRecipientsTip": "Fügen Sie eine Liste ein oder drücken Sie nach jeder Adresse die Eingabetaste.",
"members.emailRequired": "Geben Sie mindestens eine E-Mail-Adresse ein.",
"members.failedInvitationEmails": "Die folgenden Benutzer wurden nicht erfolgreich eingeladen",
"members.invalidRole": "Die ausgewählte Rolle ist nicht mehr verfügbar. Wählen Sie eine andere Rolle.",
"members.invitationLink": "Einladungslink",
"members.invitationSent": "Einladung gesendet",
"members.invitationSentTip": "Einladung gesendet, und sie können sich bei Dify anmelden, um auf Ihre Teamdaten zuzugreifen.",
"members.invite": "Hinzufügen",
"members.inviteFailed": "Die Einladungen konnten nicht gesendet werden. Versuchen Sie es erneut.",
"members.inviteLimitExceeded": "Es sind nicht genügend Plätze für alle Empfänger verfügbar.",
"members.inviteTeamMember": "Teammitglied hinzufügen",
"members.inviteTeamMemberTip": "Sie können direkt nach der Anmeldung auf Ihre Teamdaten zugreifen.",
"members.invitedAsRole": "Eingeladen als {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Kann nur Apps verwenden, kann keine Apps erstellen",
"members.ok": "OK",
"members.pending": "Ausstehend...",
"members.recipientCountExceedsSeats": "Sie können die Einladungen trotzdem senden. Bereits vorhandene Mitglieder belegen keinen weiteren Platz.",
"members.recipientCount_one": "{{count}} Empfänger",
"members.recipientCount_other": "{{count}} Empfänger",
"members.removeFromTeam": "Vom Team entfernen",
"members.removeFromTeamConfirmDescription": "Bestätige, dass dieses Mitglied entfernt werden soll. Diese Aktion kann nicht rückgängig gemacht werden.",
"members.removeFromTeamConfirmTitle": "{{memberName}} aus dem Team entfernen",
"members.role": "ROLLE",
"members.roles": "ROLLEN",
"members.seatsRemaining_one": "{{count}} verbleibender Platz",
"members.seatsRemaining_other": "{{count}} verbleibende Plätze",
"members.selectRole": "Wählen Sie eine Rolle aus",
"members.sendInvite": "Einladung senden",
"members.sendInviteCount_one": "{{count}} Einladung senden",
"members.sendInviteCount_other": "{{count}} Einladungen senden",
"members.transferModal.codeLabel": "Bestätigungscode",
"members.transferModal.codePlaceholder": "Geben Sie den 6-stelligen Code ein",
"members.transferModal.continue": "Fortsetzen",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Invalid Email Format",
"members.emailNotSetup": "Email server is not set up, so invitation emails cannot be sent. Please notify users of the invitation link that will be issued after invitation instead.",
"members.emailPlaceholder": "Please input emails",
"members.emailPlaceholder": "Type or paste email addresses…",
"members.emailRecipients": "Email addresses",
"members.emailRecipientsTip": "Paste a list, or press Enter after each address.",
"members.emailRequired": "Add at least one email address.",
"members.failedInvitationEmails": "Below users were not invited successfully",
"members.invalidRole": "This role is no longer available. Select another role.",
"members.invitationLink": "Invitation Link",
"members.invitationSent": "Invitation sent",
"members.invitationSentTip": "Invitation sent, and they can sign in to Dify to access your team data.",
"members.invite": "Add",
"members.inviteFailed": "Invitations could not be sent. Try again.",
"members.inviteLimitExceeded": "There are not enough member seats for these invitations.",
"members.inviteTeamMember": "Add team member",
"members.inviteTeamMemberTip": "They can access your team data directly after signing in.",
"members.invitedAsRole": "Invited as {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Only can use apps, can not build apps",
"members.ok": "OK",
"members.pending": "Pending...",
"members.recipientCountExceedsSeats": "You can still submit. Existing members will not use another seat.",
"members.recipientCount_one": "{{count}} recipient",
"members.recipientCount_other": "{{count}} recipients",
"members.removeFromTeam": "Remove from team",
"members.removeFromTeamConfirmDescription": "Confirm removing this member. This action cannot be undone.",
"members.removeFromTeamConfirmTitle": "Remove {{memberName}} from team",
"members.role": "ROLE",
"members.role": "Role",
"members.roles": "ROLES",
"members.seatsRemaining_one": "{{count}} seat remaining",
"members.seatsRemaining_other": "{{count}} seats remaining",
"members.selectRole": "Select a role",
"members.sendInvite": "Send Invite",
"members.sendInvite": "Send invitations",
"members.sendInviteCount_one": "Send {{count}} invitation",
"members.sendInviteCount_other": "Send {{count}} invitations",
"members.transferModal.codeLabel": "Verification code",
"members.transferModal.codePlaceholder": "Paste the 6-digit code",
"members.transferModal.continue": "Continue",

View File

@ -228,12 +228,18 @@
"members.email": "Correo electrónico",
"members.emailInvalid": "Formato de correo electrónico inválido",
"members.emailNotSetup": "El servidor de correo no está configurado, por lo que no se pueden enviar correos de invitación. En su lugar, notifique a los usuarios el enlace de invitación que se emitirá después de la invitación.",
"members.emailPlaceholder": "Por favor ingresa correos electrónicos",
"members.emailPlaceholder": "Escribe o pega direcciones de correo electrónico…",
"members.emailRecipients": "Direcciones de correo electrónico",
"members.emailRecipientsTip": "Pega una lista o pulsa Intro después de cada dirección.",
"members.emailRequired": "Introduce al menos una dirección de correo electrónico.",
"members.failedInvitationEmails": "Los siguientes usuarios no fueron invitados exitosamente",
"members.invalidRole": "El rol seleccionado ya no está disponible. Selecciona otro rol.",
"members.invitationLink": "Enlace de invitación",
"members.invitationSent": "Invitación enviada",
"members.invitationSentTip": "Invitación enviada, y pueden iniciar sesión en Dify para acceder a tus datos del equipo.",
"members.invite": "Agregar",
"members.inviteFailed": "No se pudieron enviar las invitaciones. Inténtalo de nuevo.",
"members.inviteLimitExceeded": "No hay suficientes plazas disponibles para todos los destinatarios.",
"members.inviteTeamMember": "Agregar miembro del equipo",
"members.inviteTeamMemberTip": "Pueden acceder a tus datos del equipo directamente después de iniciar sesión.",
"members.invitedAsRole": "Invitado como {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Solo puede usar aplicaciones, no puede crear aplicaciones",
"members.ok": "OK",
"members.pending": "Pendiente...",
"members.recipientCountExceedsSeats": "Aun así puedes enviar las invitaciones. Los miembros existentes no ocuparán otra plaza.",
"members.recipientCount_one": "{{count}} destinatario",
"members.recipientCount_other": "{{count}} destinatarios",
"members.removeFromTeam": "Eliminar del espacio de trabajo",
"members.removeFromTeamConfirmDescription": "Confirma que quieres eliminar a este miembro. Esta acción no se puede deshacer.",
"members.removeFromTeamConfirmTitle": "Eliminar a {{memberName}} del equipo",
"members.role": "ROL",
"members.roles": "ROLES",
"members.seatsRemaining_one": "{{count}} plaza restante",
"members.seatsRemaining_other": "{{count}} plazas restantes",
"members.selectRole": "Selecciona un rol",
"members.sendInvite": "Enviar invitación",
"members.sendInviteCount_one": "Enviar {{count}} invitación",
"members.sendInviteCount_other": "Enviar {{count}} invitaciones",
"members.transferModal.codeLabel": "Código de verificación",
"members.transferModal.codePlaceholder": "Pegue el código de 6 dígitos",
"members.transferModal.continue": "Continuar",

View File

@ -228,12 +228,18 @@
"members.email": "ایمیل",
"members.emailInvalid": "فرمت ایمیل نامعتبر است",
"members.emailNotSetup": "سرور ایمیل راه‌اندازی نشده است، بنابراین ایمیل‌های دعوت نمی‌توانند ارسال شوند. لطفاً کاربران را از لینک دعوت که پس از دعوت صادر خواهد شد مطلع کنید。",
"members.emailPlaceholder": "لطفاً ایمیل‌ها را وارد کنید",
"members.emailPlaceholder": "نشانی‌های ایمیل را تایپ یا جای‌گذاری کنید…",
"members.emailRecipients": "نشانی‌های ایمیل",
"members.emailRecipientsTip": "یک فهرست را جای‌گذاری کنید یا پس از هر نشانی Enter را فشار دهید.",
"members.emailRequired": "حداقل یک نشانی ایمیل وارد کنید.",
"members.failedInvitationEmails": "کاربران زیر با موفقیت دعوت نشدند",
"members.invalidRole": "نقش انتخاب‌شده دیگر در دسترس نیست. نقش دیگری را انتخاب کنید.",
"members.invitationLink": "لینک دعوت",
"members.invitationSent": "دعوت‌نامه ارسال شد",
"members.invitationSentTip": "دعوت‌نامه ارسال شد و آنها می‌توانند وارد Dify شوند تا به داده‌های تیم شما دسترسی پیدا کنند.",
"members.invite": "افزودن",
"members.inviteFailed": "ارسال دعوت‌نامه‌ها انجام نشد. دوباره تلاش کنید.",
"members.inviteLimitExceeded": "برای همه گیرندگان ظرفیت کافی وجود ندارد.",
"members.inviteTeamMember": "افزودن عضو تیم",
"members.inviteTeamMemberTip": "آنها می‌توانند پس از ورود به سیستم، مستقیماً به داده‌های تیم شما دسترسی پیدا کنند.",
"members.invitedAsRole": "به عنوان {{role}} دعوت شده",
@ -254,13 +260,20 @@
"members.normalTip": "فقط می‌تواند از برنامه‌ها استفاده کند، نمی‌تواند برنامه بسازد",
"members.ok": "تایید",
"members.pending": "در انتظار...",
"members.recipientCountExceedsSeats": "همچنان می‌توانید دعوت‌نامه‌ها را ارسال کنید. اعضای موجود ظرفیت دیگری اشغال نمی‌کنند.",
"members.recipientCount_one": "{{count}} گیرنده",
"members.recipientCount_other": "{{count}} گیرنده",
"members.removeFromTeam": "حذف از تیم",
"members.removeFromTeamConfirmDescription": "حذف این عضو را تأیید کنید. این عمل قابل بازگشت نیست.",
"members.removeFromTeamConfirmTitle": "حذف {{memberName}} از تیم",
"members.role": "نقش",
"members.roles": "نقش‌ها",
"members.seatsRemaining_one": "{{count}} جایگاه باقی‌مانده",
"members.seatsRemaining_other": "{{count}} جایگاه باقی‌مانده",
"members.selectRole": "یک نقش انتخاب کنید",
"members.sendInvite": "ارسال دعوت",
"members.sendInviteCount_one": "ارسال {{count}} دعوت‌نامه",
"members.sendInviteCount_other": "ارسال {{count}} دعوت‌نامه",
"members.transferModal.codeLabel": "کد تأیید",
"members.transferModal.codePlaceholder": "کد ۶ رقمی را وارد کنید",
"members.transferModal.continue": "ادامه دهید",

View File

@ -228,12 +228,18 @@
"members.email": "Courrier électronique",
"members.emailInvalid": "Format de courriel invalide",
"members.emailNotSetup": "Le serveur de messagerie n'est pas configuré, les e-mails d'invitation ne peuvent donc pas être envoyés. Veuillez informer les utilisateurs du lien d'invitation qui sera émis après l'invitation.",
"members.emailPlaceholder": "Veuillez entrer des emails",
"members.emailPlaceholder": "Saisissez ou collez des adresses e-mail…",
"members.emailRecipients": "Adresses e-mail",
"members.emailRecipientsTip": "Collez une liste ou appuyez sur Entrée après chaque adresse.",
"members.emailRequired": "Saisissez au moins une adresse e-mail.",
"members.failedInvitationEmails": "Les utilisateurs ci-dessous n'ont pas été invités avec succès",
"members.invalidRole": "Le rôle sélectionné nest plus disponible. Sélectionnez-en un autre.",
"members.invitationLink": "Lien d'invitation",
"members.invitationSent": "Invitation envoyée",
"members.invitationSentTip": "Invitation envoyée, et ils peuvent se connecter à Dify pour accéder aux données de votre équipe.",
"members.invite": "Ajouter",
"members.inviteFailed": "Impossible denvoyer les invitations. Réessayez.",
"members.inviteLimitExceeded": "Il ny a pas assez de places disponibles pour tous les destinataires.",
"members.inviteTeamMember": "Ajouter un membre de l'équipe",
"members.inviteTeamMemberTip": "Ils peuvent accéder directement à vos données d'équipe après s'être connectés.",
"members.invitedAsRole": "Invité en tant que {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Peut seulement utiliser des applications, ne peut pas construire des applications",
"members.ok": "D'accord",
"members.pending": "En attente...",
"members.recipientCountExceedsSeats": "Vous pouvez quand même envoyer les invitations. Les membres existants noccuperont pas de place supplémentaire.",
"members.recipientCount_one": "{{count}} destinataire",
"members.recipientCount_other": "{{count}} destinataires",
"members.removeFromTeam": "Retirer de l'équipe",
"members.removeFromTeamConfirmDescription": "Confirmez le retrait de ce membre. Cette action est irréversible.",
"members.removeFromTeamConfirmTitle": "Retirer {{memberName}} de l'équipe",
"members.role": "RÔLE",
"members.roles": "RÔLES",
"members.seatsRemaining_one": "{{count}} place restante",
"members.seatsRemaining_other": "{{count}} places restantes",
"members.selectRole": "Sélectionner un rôle",
"members.sendInvite": "Envoyer une invitation",
"members.sendInviteCount_one": "Envoyer {{count}} invitation",
"members.sendInviteCount_other": "Envoyer {{count}} invitations",
"members.transferModal.codeLabel": "Code de vérification",
"members.transferModal.codePlaceholder": "Collez le code à 6 chiffres",
"members.transferModal.continue": "Continuer",

View File

@ -228,12 +228,18 @@
"members.email": "ईमेल",
"members.emailInvalid": "अवैध ईमेल प्रारूप",
"members.emailNotSetup": "ईमेल सर्वर सेट नहीं है, इसलिए आमंत्रण ईमेल नहीं भेजे जा सकते। कृपया उपयोगकर्ताओं को आमंत्रण के बाद जारी किए जाने वाले आमंत्रण लिंक के बारे में सूचित करें。",
"members.emailPlaceholder": "कृपया ईमेल दर्ज करें",
"members.emailPlaceholder": "ईमेल पते टाइप या पेस्ट करें…",
"members.emailRecipients": "ईमेल पते",
"members.emailRecipientsTip": "सूची पेस्ट करें या हर पते के बाद Enter दबाएँ।",
"members.emailRequired": "कम से कम एक ईमेल पता दर्ज करें।",
"members.failedInvitationEmails": "नीचे दिए गए उपयोगकर्ताओं को सफलतापूर्वक आमंत्रित नहीं किया गया",
"members.invalidRole": "चुनी गई भूमिका अब उपलब्ध नहीं है। कोई दूसरी भूमिका चुनें।",
"members.invitationLink": "आमंत्रण लिंक",
"members.invitationSent": "आमंत्रण भेजा गया",
"members.invitationSentTip": "आमंत्रण भेजा गया, और वे साइन इन करके आपकी टीम डेटा तक पहुंच सकते हैं।",
"members.invite": "जोड़ें",
"members.inviteFailed": "आमंत्रण नहीं भेजे जा सके। फिर से कोशिश करें।",
"members.inviteLimitExceeded": "सभी प्राप्तकर्ताओं के लिए पर्याप्त सीटें उपलब्ध नहीं हैं।",
"members.inviteTeamMember": "टीम सदस्य जोड़ें",
"members.inviteTeamMemberTip": "वे साइन इन करने के बाद सीधे आपकी टीम डेटा तक पहुंच सकते हैं।",
"members.invitedAsRole": "{{role}} के रूप में आमंत्रित किया गया",
@ -254,13 +260,20 @@
"members.normalTip": "केवल ऐप्स का उपयोग कर सकते हैं, ऐप्स नहीं बना सकते",
"members.ok": "ठीक है",
"members.pending": "लंबित...",
"members.recipientCountExceedsSeats": "आप फिर भी आमंत्रण भेज सकते हैं। मौजूदा सदस्य कोई अतिरिक्त सीट नहीं लेंगे।",
"members.recipientCount_one": "{{count}} प्राप्तकर्ता",
"members.recipientCount_other": "{{count}} प्राप्तकर्ता",
"members.removeFromTeam": "टीम से हटाएं",
"members.removeFromTeamConfirmDescription": "इस सदस्य को हटाने की पुष्टि करें। यह कार्रवाई वापस नहीं की जा सकती।",
"members.removeFromTeamConfirmTitle": "{{memberName}} को टीम से हटाएं",
"members.role": "भूमिका",
"members.roles": "भूमिकाएं",
"members.seatsRemaining_one": "{{count}} सीट शेष",
"members.seatsRemaining_other": "{{count}} सीटें शेष",
"members.selectRole": "एक भूमिका चुनें",
"members.sendInvite": "आमंत्रण भेजें",
"members.sendInviteCount_one": "{{count}} आमंत्रण भेजें",
"members.sendInviteCount_other": "{{count}} आमंत्रण भेजें",
"members.transferModal.codeLabel": "पुष्टिकरण कोड",
"members.transferModal.codePlaceholder": "6 अंकों का कोड पेस्ट करें",
"members.transferModal.continue": "जारी रखें",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Format Email Tidak Valid",
"members.emailNotSetup": "Server email tidak disiapkan, sehingga email undangan tidak dapat dikirim. Harap beri tahu pengguna tentang tautan undangan yang akan dikeluarkan setelah undangan.",
"members.emailPlaceholder": "Silakan masukkan email",
"members.emailPlaceholder": "Ketik atau tempel alamat email…",
"members.emailRecipients": "Alamat email",
"members.emailRecipientsTip": "Tempel daftar atau tekan Enter setelah setiap alamat.",
"members.emailRequired": "Masukkan setidaknya satu alamat email.",
"members.failedInvitationEmails": "Pengguna di bawah ini tidak berhasil diundang",
"members.invalidRole": "Peran yang dipilih sudah tidak tersedia. Pilih peran lain.",
"members.invitationLink": "Tautan Undangan",
"members.invitationSent": "Undangan terkirim",
"members.invitationSentTip": "Undangan terkirim, dan mereka dapat masuk ke Dify untuk mengakses data tim Anda.",
"members.invite": "Tambah",
"members.inviteFailed": "Undangan tidak dapat dikirim. Coba lagi.",
"members.inviteLimitExceeded": "Slot yang tersedia tidak cukup untuk semua penerima.",
"members.inviteTeamMember": "Tambahkan anggota tim",
"members.inviteTeamMemberTip": "Mereka dapat mengakses data tim Anda langsung setelah masuk.",
"members.invitedAsRole": "Diundang sebagai {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Hanya dapat menggunakan aplikasi, tidak dapat membuat aplikasi",
"members.ok": "OKE",
"members.pending": "Tertunda...",
"members.recipientCountExceedsSeats": "Anda tetap dapat mengirim undangan. Anggota yang sudah ada tidak akan menggunakan slot tambahan.",
"members.recipientCount_one": "{{count}} penerima",
"members.recipientCount_other": "{{count}} penerima",
"members.removeFromTeam": "Hapus dari tim",
"members.removeFromTeamConfirmDescription": "Konfirmasi untuk menghapus anggota ini. Tindakan ini tidak dapat dibatalkan.",
"members.removeFromTeamConfirmTitle": "Hapus {{memberName}} dari tim",
"members.role": "PERAN",
"members.roles": "PERAN",
"members.seatsRemaining_one": "{{count}} slot tersisa",
"members.seatsRemaining_other": "{{count}} slot tersisa",
"members.selectRole": "Pilih peran",
"members.sendInvite": "Kirim Undangan",
"members.sendInviteCount_one": "Kirim {{count}} undangan",
"members.sendInviteCount_other": "Kirim {{count}} undangan",
"members.transferModal.codeLabel": "Kode verifikasi",
"members.transferModal.codePlaceholder": "Tempel kode 6 digit",
"members.transferModal.continue": "Terus",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Formato Email non valido",
"members.emailNotSetup": "Il server email non è configurato, quindi non è possibile inviare email di invito. Si prega di notificare agli utenti il link di invito che verrà emesso dopo l'invito.",
"members.emailPlaceholder": "Per favore inserisci le email",
"members.emailPlaceholder": "Digita o incolla gli indirizzi e-mail…",
"members.emailRecipients": "Indirizzi e-mail",
"members.emailRecipientsTip": "Incolla un elenco o premi Invio dopo ogni indirizzo.",
"members.emailRequired": "Inserisci almeno un indirizzo e-mail.",
"members.failedInvitationEmails": "Gli utenti seguenti non sono stati invitati con successo",
"members.invalidRole": "Il ruolo selezionato non è più disponibile. Seleziona un altro ruolo.",
"members.invitationLink": "Link di Invito",
"members.invitationSent": "Invito inviato",
"members.invitationSentTip": "Invito inviato, e possono accedere a Dify per accedere ai dati del tuo team.",
"members.invite": "Aggiungi",
"members.inviteFailed": "Impossibile inviare gli inviti. Riprova.",
"members.inviteLimitExceeded": "Non ci sono posti sufficienti per tutti i destinatari.",
"members.inviteTeamMember": "Aggiungi membro del team",
"members.inviteTeamMemberTip": "Potranno accedere ai dati del tuo team direttamente dopo aver effettuato l'accesso.",
"members.invitedAsRole": "Invitato come {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Può solo usare le app, non può crearle",
"members.ok": "OK",
"members.pending": "In attesa...",
"members.recipientCountExceedsSeats": "Puoi comunque inviare gli inviti. I membri già presenti non occuperanno un altro posto.",
"members.recipientCount_one": "{{count}} destinatario",
"members.recipientCount_other": "{{count}} destinatari",
"members.removeFromTeam": "Rimuovi dal team",
"members.removeFromTeamConfirmDescription": "Conferma la rimozione di questo membro. Questa azione non può essere annullata.",
"members.removeFromTeamConfirmTitle": "Rimuovi {{memberName}} dal team",
"members.role": "RUOLO",
"members.roles": "RUOLI",
"members.seatsRemaining_one": "{{count}} posto rimanente",
"members.seatsRemaining_other": "{{count}} posti rimanenti",
"members.selectRole": "Seleziona un ruolo",
"members.sendInvite": "Invia Invito",
"members.sendInviteCount_one": "Invia {{count}} invito",
"members.sendInviteCount_other": "Invia {{count}} inviti",
"members.transferModal.codeLabel": "Codice di verifica",
"members.transferModal.codePlaceholder": "Inserisci il codice di 6 cifre",
"members.transferModal.continue": "Continua",

View File

@ -228,12 +228,18 @@
"members.email": "メール",
"members.emailInvalid": "無効なメール形式",
"members.emailNotSetup": "メールサーバーがセットアップされていないので、招待メールを送信することはできません。代わりに招待後に発行される招待リンクをユーザーに通知してください。",
"members.emailPlaceholder": "メールを入力してください",
"members.emailPlaceholder": "メールアドレスを入力または貼り付け…",
"members.emailRecipients": "メールアドレス",
"members.emailRecipientsTip": "リストを貼り付けるか、アドレスごとに Enter を押して追加します。",
"members.emailRequired": "メールアドレスを1件以上追加してください。",
"members.failedInvitationEmails": "以下のユーザーは正常に招待されませんでした",
"members.invalidRole": "このロールは利用できなくなりました。別のロールを選択してください。",
"members.invitationLink": "招待リンク",
"members.invitationSent": "招待が送信されました",
"members.invitationSentTip": "招待が送信され、彼らは Dify にサインインしてあなたのチームデータにアクセスできます。",
"members.invite": "招待",
"members.inviteFailed": "招待を送信できませんでした。もう一度お試しください。",
"members.inviteLimitExceeded": "メンバー枠が不足しているため、これらの招待を送信できません。",
"members.inviteTeamMember": "チームメンバーを招待する",
"members.inviteTeamMemberTip": "彼らはサインイン後、直接あなたのチームデータにアクセスできます。",
"members.invitedAsRole": "{{role}}として招待されました",
@ -254,13 +260,20 @@
"members.normalTip": "アプリの使用のみが可能で、アプリの構築はできません",
"members.ok": "OK",
"members.pending": "保留中...",
"members.recipientCountExceedsSeats": "このまま送信できます。既存メンバーは追加の枠を使用しません。",
"members.recipientCount_one": "受信者 {{count}} 件",
"members.recipientCount_other": "受信者 {{count}} 件",
"members.removeFromTeam": "チームから削除",
"members.removeFromTeamConfirmDescription": "このメンバーを削除することを確認してください。この操作は元に戻せません。",
"members.removeFromTeamConfirmTitle": "{{memberName}}をチームから削除",
"members.role": "ロール",
"members.roles": "ロール",
"members.seatsRemaining_one": "残り {{count}} 枠",
"members.seatsRemaining_other": "残り {{count}} 枠",
"members.selectRole": "ロールを選択してください",
"members.sendInvite": "招待を送る",
"members.sendInviteCount_one": "{{count}} 件の招待を送信",
"members.sendInviteCount_other": "{{count}} 件の招待を送信",
"members.transferModal.codeLabel": "認証コード",
"members.transferModal.codePlaceholder": "6 桁のコードを入力してください",
"members.transferModal.continue": "続行する",

View File

@ -228,12 +228,18 @@
"members.email": "이메일",
"members.emailInvalid": "유효하지 않은 이메일 형식",
"members.emailNotSetup": "이메일 서버가 설정되지 않아 초대 이메일을 보낼 수 없습니다. 대신 초대 후 발급되는 초대 링크를 사용자에게 알려주세요.",
"members.emailPlaceholder": "이메일 입력",
"members.emailPlaceholder": "이메일 주소를 입력하거나 붙여넣으세요…",
"members.emailRecipients": "이메일 주소",
"members.emailRecipientsTip": "목록을 붙여넣거나 각 주소 뒤에 Enter를 누르세요.",
"members.emailRequired": "이메일 주소를 하나 이상 입력하세요.",
"members.failedInvitationEmails": "다음 사용자들은 성공적으로 초대되지 않았습니다",
"members.invalidRole": "선택한 역할을 더 이상 사용할 수 없습니다. 다른 역할을 선택하세요.",
"members.invitationLink": "초대 링크",
"members.invitationSent": "초대가 전송되었습니다",
"members.invitationSentTip": "초대가 전송되었으며, 그들은 Dify 에 로그인하여 당신의 팀 데이터에 액세스할 수 있습니다.",
"members.invite": "초대",
"members.inviteFailed": "초대장을 보내지 못했습니다. 다시 시도하세요.",
"members.inviteLimitExceeded": "모든 수신자를 초대할 수 있는 좌석이 부족합니다.",
"members.inviteTeamMember": "팀 멤버 초대",
"members.inviteTeamMemberTip": "로그인 후에 바로 팀 데이터에 액세스할 수 있습니다.",
"members.invitedAsRole": "{{role}}로 초대되었습니다",
@ -254,13 +260,20 @@
"members.normalTip": "앱 사용만 가능하고 앱 빌드는 불가능",
"members.ok": "확인",
"members.pending": "대기 중...",
"members.recipientCountExceedsSeats": "초대장은 계속 보낼 수 있습니다. 기존 멤버는 좌석을 추가로 사용하지 않습니다.",
"members.recipientCount_one": "수신자 {{count}}명",
"members.recipientCount_other": "수신자 {{count}}명",
"members.removeFromTeam": "팀에서 제거",
"members.removeFromTeamConfirmDescription": "이 멤버를 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"members.removeFromTeamConfirmTitle": "{{memberName}}님을 팀에서 제거",
"members.role": "역할",
"members.roles": "역할",
"members.seatsRemaining_one": "남은 좌석 {{count}}개",
"members.seatsRemaining_other": "남은 좌석 {{count}}개",
"members.selectRole": "역할 선택",
"members.sendInvite": "초대 보내기",
"members.sendInviteCount_one": "초대 {{count}}개 보내기",
"members.sendInviteCount_other": "초대 {{count}}개 보내기",
"members.transferModal.codeLabel": "검증 코드",
"members.transferModal.codePlaceholder": "6자리 코드를 붙여넣으세요",
"members.transferModal.continue": "계속하다",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Invalid Email Format",
"members.emailNotSetup": "Email server is not set up, so invitation emails cannot be sent. Please notify users of the invitation link that will be issued after invitation instead.",
"members.emailPlaceholder": "Please input emails",
"members.emailPlaceholder": "Typ of plak e-mailadressen…",
"members.emailRecipients": "E-mailadressen",
"members.emailRecipientsTip": "Plak een lijst of druk na elk adres op Enter.",
"members.emailRequired": "Voer ten minste één e-mailadres in.",
"members.failedInvitationEmails": "Below users were not invited successfully",
"members.invalidRole": "De geselecteerde rol is niet meer beschikbaar. Selecteer een andere rol.",
"members.invitationLink": "Invitation Link",
"members.invitationSent": "Invitation sent",
"members.invitationSentTip": "Invitation sent, and they can sign in to Dify to access your team data.",
"members.invite": "Add",
"members.inviteFailed": "De uitnodigingen konden niet worden verzonden. Probeer het opnieuw.",
"members.inviteLimitExceeded": "Er zijn niet genoeg plaatsen beschikbaar voor alle ontvangers.",
"members.inviteTeamMember": "Add team member",
"members.inviteTeamMemberTip": "They can access your team data directly after signing in.",
"members.invitedAsRole": "Uitgenodigd als {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Only can use apps, can not build apps",
"members.ok": "OK",
"members.pending": "Pending...",
"members.recipientCountExceedsSeats": "U kunt de uitnodigingen nog steeds verzenden. Bestaande leden nemen geen extra plaats in.",
"members.recipientCount_one": "{{count}} ontvanger",
"members.recipientCount_other": "{{count}} ontvangers",
"members.removeFromTeam": "Remove from team",
"members.removeFromTeamConfirmDescription": "Bevestig dat je dit lid wilt verwijderen. Deze actie kan niet ongedaan worden gemaakt.",
"members.removeFromTeamConfirmTitle": "{{memberName}} uit team verwijderen",
"members.role": "ROL",
"members.roles": "ROLLEN",
"members.seatsRemaining_one": "{{count}} resterende plaats",
"members.seatsRemaining_other": "{{count}} resterende plaatsen",
"members.selectRole": "Selecteer een rol",
"members.sendInvite": "Send Invite",
"members.sendInviteCount_one": "{{count}} uitnodiging verzenden",
"members.sendInviteCount_other": "{{count}} uitnodigingen verzenden",
"members.transferModal.codeLabel": "Verification code",
"members.transferModal.codePlaceholder": "Paste the 6-digit code",
"members.transferModal.continue": "Continue",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Nieprawidłowy format e-maila",
"members.emailNotSetup": "Serwer poczty nie jest skonfigurowany, więc nie można wysyłać zaproszeń e-mail. Proszę powiadomić użytkowników o linku do zaproszenia, który zostanie wydany po zaproszeniu.",
"members.emailPlaceholder": "Proszę podać adresy e-mail",
"members.emailPlaceholder": "Wpisz lub wklej adresy e-mail…",
"members.emailRecipients": "Adresy e-mail",
"members.emailRecipientsTip": "Wklej listę lub naciśnij Enter po każdym adresie.",
"members.emailRequired": "Wpisz co najmniej jeden adres e-mail.",
"members.failedInvitationEmails": "Poniższe osoby nie zostały pomyślnie zaproszone",
"members.invalidRole": "Wybrana rola nie jest już dostępna. Wybierz inną rolę.",
"members.invitationLink": "Link zaproszenia",
"members.invitationSent": "Zaproszenie wysłane",
"members.invitationSentTip": "Zaproszenie zostało wysłane, a oni mogą zalogować się do Dify, aby uzyskać dostęp do danych Twojego zespołu.",
"members.invite": "Dodaj",
"members.inviteFailed": "Nie udało się wysłać zaproszeń. Spróbuj ponownie.",
"members.inviteLimitExceeded": "Brak wystarczającej liczby miejsc dla wszystkich odbiorców.",
"members.inviteTeamMember": "Dodaj członka zespołu",
"members.inviteTeamMemberTip": "Mogą uzyskać bezpośredni dostęp do danych Twojego zespołu po zalogowaniu.",
"members.invitedAsRole": "Zaproszony jako {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Może tylko korzystać z aplikacji, nie może tworzyć aplikacji",
"members.ok": "OK",
"members.pending": "Oczekujący...",
"members.recipientCountExceedsSeats": "Nadal możesz wysłać zaproszenia. Obecni członkowie nie zajmą kolejnego miejsca.",
"members.recipientCount_one": "{{count}} odbiorca",
"members.recipientCount_other": "{{count}} odbiorcy",
"members.removeFromTeam": "Usuń z zespołu",
"members.removeFromTeamConfirmDescription": "Potwierdź usunięcie tego członka. Tej czynności nie można cofnąć.",
"members.removeFromTeamConfirmTitle": "Usuń {{memberName}} z zespołu",
"members.role": "ROLA",
"members.roles": "ROLE",
"members.seatsRemaining_one": "{{count}} wolne miejsce",
"members.seatsRemaining_other": "{{count}} wolnego miejsca",
"members.selectRole": "Wybierz rolę",
"members.sendInvite": "Wyślij zaproszenie",
"members.sendInviteCount_one": "Wyślij {{count}} zaproszenie",
"members.sendInviteCount_other": "Wyślij {{count}} zaproszenia",
"members.transferModal.codeLabel": "Kod weryfikacyjny",
"members.transferModal.codePlaceholder": "Wklej 6-cyfrowy kod",
"members.transferModal.continue": "Kontynuuj",

View File

@ -228,12 +228,18 @@
"members.email": "E-mail",
"members.emailInvalid": "Formato de e-mail inválido",
"members.emailNotSetup": "O servidor de e-mail não está configurado, então os e-mails de convite não podem ser enviados. Por favor, notifique os usuários sobre o link de convite que será emitido após o convite.",
"members.emailPlaceholder": "Por favor, insira e-mails",
"members.emailPlaceholder": "Digite ou cole endereços de e-mail…",
"members.emailRecipients": "Endereços de e-mail",
"members.emailRecipientsTip": "Cole uma lista ou pressione Enter após cada endereço.",
"members.emailRequired": "Insira pelo menos um endereço de e-mail.",
"members.failedInvitationEmails": "Os seguintes usuários não foram convidados com sucesso",
"members.invalidRole": "A função selecionada não está mais disponível. Selecione outra função.",
"members.invitationLink": "Link do Convite",
"members.invitationSent": "Convite enviado",
"members.invitationSentTip": "Convite enviado e eles podem fazer login no Dify para acessar os dados da sua equipe.",
"members.invite": "Adicionar",
"members.inviteFailed": "Não foi possível enviar os convites. Tente novamente.",
"members.inviteLimitExceeded": "Não há vagas suficientes para todos os destinatários.",
"members.inviteTeamMember": "Adicionar membro da equipe",
"members.inviteTeamMemberTip": "Eles podem acessar os dados da sua equipe diretamente após fazer login.",
"members.invitedAsRole": "Convidado como {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Só pode usar aplicativos, não pode criar aplicativos",
"members.ok": "OK",
"members.pending": "Pendente...",
"members.recipientCountExceedsSeats": "Você ainda pode enviar os convites. Os membros existentes não ocuparão outra vaga.",
"members.recipientCount_one": "{{count}} destinatário",
"members.recipientCount_other": "{{count}} destinatários",
"members.removeFromTeam": "Remover da equipe",
"members.removeFromTeamConfirmDescription": "Confirme a remoção deste membro. Esta ação não pode ser desfeita.",
"members.removeFromTeamConfirmTitle": "Remover {{memberName}} da equipe",
"members.role": "FUNÇÃO",
"members.roles": "FUNÇÕES",
"members.seatsRemaining_one": "{{count}} vaga restante",
"members.seatsRemaining_other": "{{count}} vagas restantes",
"members.selectRole": "Selecione uma função",
"members.sendInvite": "Enviar Convite",
"members.sendInviteCount_one": "Enviar {{count}} convite",
"members.sendInviteCount_other": "Enviar {{count}} convites",
"members.transferModal.codeLabel": "Código de verificação",
"members.transferModal.codePlaceholder": "Cole o código de 6 dígitos",
"members.transferModal.continue": "Continue",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Format de email invalid",
"members.emailNotSetup": "Serverul de e-mail nu este configurat, astfel încât e-mailurile de invitație nu pot fi trimise. Vă rugăm să notificați utilizatorii despre linkul de invitație care va fi emis după invitație.",
"members.emailPlaceholder": "Vă rugăm să introduceți emailuri",
"members.emailPlaceholder": "Tastați sau lipiți adrese de e-mail…",
"members.emailRecipients": "Adrese de e-mail",
"members.emailRecipientsTip": "Lipiți o listă sau apăsați Enter după fiecare adresă.",
"members.emailRequired": "Introduceți cel puțin o adresă de e-mail.",
"members.failedInvitationEmails": "Următorii utilizatori nu au fost invitați cu succes",
"members.invalidRole": "Rolul selectat nu mai este disponibil. Selectați alt rol.",
"members.invitationLink": "Link de invitație",
"members.invitationSent": "Invitație trimisă",
"members.invitationSentTip": "Invitația a fost trimisă și pot să se autentifice în Dify pentru a accesa datele echipei dvs.",
"members.invite": "Adaugă",
"members.inviteFailed": "Invitațiile nu au putut fi trimise. Încercați din nou.",
"members.inviteLimitExceeded": "Nu sunt suficiente locuri disponibile pentru toți destinatarii.",
"members.inviteTeamMember": "Adaugă membru în echipă",
"members.inviteTeamMemberTip": "Pot accesa direct datele echipei dvs. după autentificare.",
"members.invitedAsRole": "Invitat ca {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Poate doar utiliza aplicații, nu poate construi aplicații",
"members.ok": "OK",
"members.pending": "În așteptare...",
"members.recipientCountExceedsSeats": "Puteți trimite invitațiile în continuare. Membrii existenți nu vor ocupa un loc suplimentar.",
"members.recipientCount_one": "{{count}} destinatar",
"members.recipientCount_other": "{{count}} destinatari",
"members.removeFromTeam": "Elimină din echipă",
"members.removeFromTeamConfirmDescription": "Confirmă eliminarea acestui membru. Această acțiune nu poate fi anulată.",
"members.removeFromTeamConfirmTitle": "Elimină {{memberName}} din echipă",
"members.role": "ROL",
"members.roles": "ROLURI",
"members.seatsRemaining_one": "{{count}} loc rămas",
"members.seatsRemaining_other": "{{count}} locuri rămase",
"members.selectRole": "Selectați un rol",
"members.sendInvite": "Trimite invitație",
"members.sendInviteCount_one": "Trimite {{count}} invitație",
"members.sendInviteCount_other": "Trimite {{count}} invitații",
"members.transferModal.codeLabel": "Cod de verificare",
"members.transferModal.codePlaceholder": "Introduceți codul de 6 cifre",
"members.transferModal.continue": "Continuați",

View File

@ -228,12 +228,18 @@
"members.email": "Электронная почта",
"members.emailInvalid": "Неверный формат электронной почты",
"members.emailNotSetup": "Почтовый сервер не настроен, поэтому приглашения по электронной почте не могут быть отправлены. Пожалуйста, уведомите пользователей о ссылке для приглашения, которая будет выдана после приглашения.",
"members.emailPlaceholder": "Пожалуйста, введите адреса электронной почты",
"members.emailPlaceholder": "Введите или вставьте адреса электронной почты…",
"members.emailRecipients": "Адреса электронной почты",
"members.emailRecipientsTip": "Вставьте список или нажимайте Enter после каждого адреса.",
"members.emailRequired": "Введите хотя бы один адрес электронной почты.",
"members.failedInvitationEmails": "Следующие пользователи не были успешно приглашены",
"members.invalidRole": "Выбранная роль больше недоступна. Выберите другую роль.",
"members.invitationLink": "Ссылка для приглашения",
"members.invitationSent": "Приглашение отправлено",
"members.invitationSentTip": "Приглашение отправлено, и они могут войти в Dify, чтобы получить доступ к данным вашей команды.",
"members.invite": "Добавить",
"members.inviteFailed": "Не удалось отправить приглашения. Повторите попытку.",
"members.inviteLimitExceeded": "Для всех получателей недостаточно свободных мест.",
"members.inviteTeamMember": "Добавить участника команды",
"members.inviteTeamMemberTip": "Они могут получить доступ к данным вашей команды сразу после входа в систему.",
"members.invitedAsRole": "Приглашен как {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Может только использовать приложения, не может создавать приложения",
"members.ok": "ОК",
"members.pending": "Ожидание...",
"members.recipientCountExceedsSeats": "Приглашения всё равно можно отправить. Существующие участники не займут дополнительное место.",
"members.recipientCount_one": "{{count}} получатель",
"members.recipientCount_other": "{{count}} получателя",
"members.removeFromTeam": "Удалить из команды",
"members.removeFromTeamConfirmDescription": "Подтвердите удаление этого участника. Это действие нельзя отменить.",
"members.removeFromTeamConfirmTitle": "Удалить {{memberName}} из команды",
"members.role": "РОЛЬ",
"members.roles": "РОЛИ",
"members.seatsRemaining_one": "Осталось {{count}} место",
"members.seatsRemaining_other": "Осталось {{count}} места",
"members.selectRole": "Выберите роль",
"members.sendInvite": "Отправить приглашение",
"members.sendInviteCount_one": "Отправить {{count}} приглашение",
"members.sendInviteCount_other": "Отправить {{count}} приглашения",
"members.transferModal.codeLabel": "Код подтверждения",
"members.transferModal.codePlaceholder": "Вставьте 6-значный код",
"members.transferModal.continue": "Продолжайте",

View File

@ -228,12 +228,18 @@
"members.email": "E-pošta",
"members.emailInvalid": "Neveljaven format e-pošte",
"members.emailNotSetup": "E-poštni strežnik ni nastavljen, zato vabil po e-pošti ni mogoče poslati. Prosimo, obvestite uporabnike o povezavi za povabilo, ki bo izdana po povabilu.",
"members.emailPlaceholder": "Vnesite e-poštne naslove",
"members.emailPlaceholder": "Vnesite ali prilepite e-poštne naslove…",
"members.emailRecipients": "E-poštni naslovi",
"members.emailRecipientsTip": "Prilepite seznam ali pritisnite Enter po vsakem naslovu.",
"members.emailRequired": "Vnesite vsaj en e-poštni naslov.",
"members.failedInvitationEmails": "Spodnji uporabniki niso bili uspešno povabljeni",
"members.invalidRole": "Izbrana vloga ni več na voljo. Izberite drugo vlogo.",
"members.invitationLink": "Povezava za povabilo",
"members.invitationSent": "Povabilo poslano",
"members.invitationSentTip": "Povabilo poslano, in po prijavi v Dify bodo imeli dostop do vaših podatkov ekipe.",
"members.invite": "Dodaj",
"members.inviteFailed": "Povabil ni bilo mogoče poslati. Poskusite znova.",
"members.inviteLimitExceeded": "Za vse prejemnike ni na voljo dovolj mest.",
"members.inviteTeamMember": "Dodaj člana ekipe",
"members.inviteTeamMemberTip": "Do vaših podatkov bo lahko dostopal takoj po prijavi.",
"members.invitedAsRole": "Povabljen kot {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Lahko uporablja samo aplikacije, ne more ustvarjati aplikacij",
"members.ok": "V redu",
"members.pending": "V teku...",
"members.recipientCountExceedsSeats": "Povabila lahko še vedno pošljete. Obstoječi člani ne bodo zasedli dodatnega mesta.",
"members.recipientCount_one": "{{count}} prejemnik",
"members.recipientCount_other": "{{count}} prejemnikov",
"members.removeFromTeam": "Odstrani iz ekipe",
"members.removeFromTeamConfirmDescription": "Potrdite odstranitev tega člana. Tega dejanja ni mogoče razveljaviti.",
"members.removeFromTeamConfirmTitle": "Odstrani {{memberName}} iz ekipe",
"members.role": "VLOGA",
"members.roles": "VLOGE",
"members.seatsRemaining_one": "{{count}} preostalo mesto",
"members.seatsRemaining_other": "{{count}} preostalih mest",
"members.selectRole": "Izberite vlogo",
"members.sendInvite": "Pošlji povabilo",
"members.sendInviteCount_one": "Pošlji {{count}} povabilo",
"members.sendInviteCount_other": "Pošlji {{count}} povabil",
"members.transferModal.codeLabel": "Koda za potrditev",
"members.transferModal.codePlaceholder": "Prilepite 6-mestno kodo",
"members.transferModal.continue": "Nadaljuj",

View File

@ -228,12 +228,18 @@
"members.email": "อีเมล",
"members.emailInvalid": "รูปแบบอีเมลไม่ถูกต้อง",
"members.emailNotSetup": "เซิร์ฟเวอร์อีเมลไม่ได้ตั้งค่าไว้ จึงไม่สามารถส่งอีเมลเชิญได้ กรุณาแจ้งผู้ใช้เกี่ยวกับลิงก์เชิญที่จะออกหลังจากการเชิญแทน",
"members.emailPlaceholder": "กรุณากรอกอีเมล",
"members.emailPlaceholder": "พิมพ์หรือวางที่อยู่อีเมล…",
"members.emailRecipients": "ที่อยู่อีเมล",
"members.emailRecipientsTip": "วางรายการ หรือกด Enter หลังที่อยู่แต่ละรายการ",
"members.emailRequired": "ป้อนที่อยู่อีเมลอย่างน้อยหนึ่งรายการ",
"members.failedInvitationEmails": "ผู้ใช้ด้านล่างไม่ได้รับเชิญสําเร็จ",
"members.invalidRole": "บทบาทที่เลือกไม่พร้อมใช้งานแล้ว โปรดเลือกบทบาทอื่น",
"members.invitationLink": "ลิงค์คําเชิญ",
"members.invitationSent": "ส่งคําเชิญแล้ว",
"members.invitationSentTip": "ส่งคําเชิญแล้ว และพวกเขาสามารถลงชื่อเข้าใช้ Dify เพื่อเข้าถึงข้อมูลทีมของคุณได้",
"members.invite": "เพิ่ม",
"members.inviteFailed": "ไม่สามารถส่งคำเชิญได้ โปรดลองอีกครั้ง",
"members.inviteLimitExceeded": "มีที่นั่งไม่เพียงพอสำหรับผู้รับทั้งหมด",
"members.inviteTeamMember": "เพิ่มสมาชิกในทีม",
"members.inviteTeamMemberTip": "พวกเขาสามารถเข้าถึงข้อมูลทีมของคุณได้โดยตรงหลังจากลงชื่อเข้าใช้",
"members.invitedAsRole": "ได้รับเชิญให้เป็น {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "ใช้ได้เฉพาะแอพ สร้างแอพไม่ได้",
"members.ok": "ตกลง, ได้",
"members.pending": "รอ ",
"members.recipientCountExceedsSeats": "คุณยังส่งคำเชิญได้ สมาชิกที่มีอยู่แล้วจะไม่ใช้ที่นั่งเพิ่ม",
"members.recipientCount_one": "ผู้รับ {{count}} คน",
"members.recipientCount_other": "ผู้รับ {{count}} คน",
"members.removeFromTeam": "ลบออกจากทีม",
"members.removeFromTeamConfirmDescription": "ยืนยันการลบสมาชิกนี้ การดำเนินการนี้ไม่สามารถย้อนกลับได้",
"members.removeFromTeamConfirmTitle": "ลบ {{memberName}} ออกจากทีม",
"members.role": "บทบาท",
"members.roles": "บทบาท",
"members.seatsRemaining_one": "เหลือ {{count}} ที่นั่ง",
"members.seatsRemaining_other": "เหลือ {{count}} ที่นั่ง",
"members.selectRole": "เลือกบทบาท",
"members.sendInvite": "ส่งคําเชิญ",
"members.sendInviteCount_one": "ส่งคำเชิญ {{count}} รายการ",
"members.sendInviteCount_other": "ส่งคำเชิญ {{count}} รายการ",
"members.transferModal.codeLabel": "รหัสยืนยัน",
"members.transferModal.codePlaceholder": "กรุณาวางรหัส 6 หลัก",
"members.transferModal.continue": "ดำเนินต่อไป",

View File

@ -228,12 +228,18 @@
"members.email": "E-posta",
"members.emailInvalid": "Geçersiz E-posta Formatı",
"members.emailNotSetup": "E-posta sunucusu kurulu değil, bu nedenle davet e-postaları gönderilemiyor. Lütfen kullanıcıları davetten sonra verilecek davet bağlantısı hakkında bilgilendirin.",
"members.emailPlaceholder": "Lütfen e-postaları girin",
"members.emailPlaceholder": "E-posta adreslerini yazın veya yapıştırın…",
"members.emailRecipients": "E-posta adresleri",
"members.emailRecipientsTip": "Bir liste yapıştırın veya her adresten sonra Enter'a basın.",
"members.emailRequired": "En az bir e-posta adresi girin.",
"members.failedInvitationEmails": "Aşağıdaki kullanıcılar başarıyla davet edilmedi",
"members.invalidRole": "Seçilen rol artık kullanılamıyor. Başka bir rol seçin.",
"members.invitationLink": "Davet Linki",
"members.invitationSent": "Davet gönderildi",
"members.invitationSentTip": "Davet gönderildi, Dify'ye giriş yaparak takım verilerinize erişebilirler.",
"members.invite": "Ekle",
"members.inviteFailed": "Davetler gönderilemedi. Tekrar deneyin.",
"members.inviteLimitExceeded": "Tüm alıcılar için yeterli koltuk yok.",
"members.inviteTeamMember": "Takım Üyesi Ekle",
"members.inviteTeamMemberTip": "Giriş yaptıktan sonra takım verilerinize doğrudan erişebilirler.",
"members.invitedAsRole": "{{role}} olarak davet edildi",
@ -254,13 +260,20 @@
"members.normalTip": "Sadece uygulamaları kullanabilir, uygulama oluşturamaz",
"members.ok": "Tamam",
"members.pending": "Beklemede...",
"members.recipientCountExceedsSeats": "Davetleri yine de gönderebilirsiniz. Mevcut üyeler ek bir koltuk kullanmaz.",
"members.recipientCount_one": "{{count}} alıcı",
"members.recipientCount_other": "{{count}} alıcı",
"members.removeFromTeam": "Takımdan Kaldır",
"members.removeFromTeamConfirmDescription": "Bu üyeyi kaldırmayı onaylayın. Bu işlem geri alınamaz.",
"members.removeFromTeamConfirmTitle": "{{memberName}} adlı üyeyi takımdan kaldır",
"members.role": "ROL",
"members.roles": "ROLLER",
"members.seatsRemaining_one": "{{count}} koltuk kaldı",
"members.seatsRemaining_other": "{{count}} koltuk kaldı",
"members.selectRole": "Bir rol seçin",
"members.sendInvite": "Davet Gönder",
"members.sendInviteCount_one": "{{count}} davet gönder",
"members.sendInviteCount_other": "{{count}} davet gönder",
"members.transferModal.codeLabel": "Doğrulama kodu",
"members.transferModal.codePlaceholder": "6 haneli kodu yapıştırın",
"members.transferModal.continue": "Devam et",

View File

@ -228,12 +228,18 @@
"members.email": "Електронна пошта",
"members.emailInvalid": "Недійсний формат електронної пошти",
"members.emailNotSetup": "Поштовий сервер не налаштований, тому запрошення електронною поштою не можуть бути надіслані. Будь ласка, повідомте користувачів про посилання для запрошення, яке буде видано після запрошення.",
"members.emailPlaceholder": "Будь ласка, введіть адресу електронної пошти",
"members.emailPlaceholder": "Введіть або вставте адреси електронної пошти…",
"members.emailRecipients": "Адреси електронної пошти",
"members.emailRecipientsTip": "Вставте список або натискайте Enter після кожної адреси.",
"members.emailRequired": "Введіть принаймні одну адресу електронної пошти.",
"members.failedInvitationEmails": "Наступних користувачів не було успішно запрошено",
"members.invalidRole": "Вибрана роль більше недоступна. Виберіть іншу роль.",
"members.invitationLink": "Посилання на запрошення",
"members.invitationSent": "Запрошення надіслано",
"members.invitationSentTip": "Запрошення надіслано, і вони можуть увійти в Dify, щоб отримати доступ до даних вашої команди.",
"members.invite": "Додати",
"members.inviteFailed": "Не вдалося надіслати запрошення. Спробуйте ще раз.",
"members.inviteLimitExceeded": "Для всіх одержувачів недостатньо вільних місць.",
"members.inviteTeamMember": "Додати учасника команди",
"members.inviteTeamMemberTip": "Вони зможуть отримати доступ до даних вашої команди безпосередньо після входу.",
"members.invitedAsRole": "Запрошено як {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Може лише використовувати програми, не може створювати програми",
"members.ok": "ОК",
"members.pending": "В очікуванні...",
"members.recipientCountExceedsSeats": "Запрошення все одно можна надіслати. Наявні учасники не займуть додаткового місця.",
"members.recipientCount_one": "{{count}} одержувач",
"members.recipientCount_other": "{{count}} одержувача",
"members.removeFromTeam": "Видалити з команди",
"members.removeFromTeamConfirmDescription": "Підтвердьте видалення цього учасника. Цю дію не можна скасувати.",
"members.removeFromTeamConfirmTitle": "Видалити {{memberName}} з команди",
"members.role": "РОЛЬ",
"members.roles": "РОЛІ",
"members.seatsRemaining_one": "Залишилося {{count}} місце",
"members.seatsRemaining_other": "Залишилося {{count}} місця",
"members.selectRole": "Виберіть роль",
"members.sendInvite": "Надіслати запрошення",
"members.sendInviteCount_one": "Надіслати {{count}} запрошення",
"members.sendInviteCount_other": "Надіслати {{count}} запрошення",
"members.transferModal.codeLabel": "Код перевірки",
"members.transferModal.codePlaceholder": "Вставте 6-значний код",
"members.transferModal.continue": "Продовжувати",

View File

@ -228,12 +228,18 @@
"members.email": "Email",
"members.emailInvalid": "Định dạng Email không hợp lệ",
"members.emailNotSetup": "Máy chủ email chưa được thiết lập, vì vậy không thể gửi email mời. Vui lòng thông báo cho người dùng về liên kết mời sẽ được phát hành sau khi mời.",
"members.emailPlaceholder": "Vui lòng nhập email",
"members.emailPlaceholder": "Nhập hoặc dán địa chỉ email…",
"members.emailRecipients": "Địa chỉ email",
"members.emailRecipientsTip": "Dán danh sách hoặc nhấn Enter sau mỗi địa chỉ.",
"members.emailRequired": "Nhập ít nhất một địa chỉ email.",
"members.failedInvitationEmails": "Dưới đây là danh sách email không gửi được lời mời",
"members.invalidRole": "Vai trò đã chọn không còn khả dụng. Hãy chọn vai trò khác.",
"members.invitationLink": "Liên kết Lời mời",
"members.invitationSent": "Lời mời đã được gửi",
"members.invitationSentTip": "Lời mời đã được gửi, và họ có thể đăng nhập vào Dify để truy cập vào dữ liệu nhóm của bạn.",
"members.invite": "Mời",
"members.inviteFailed": "Không thể gửi lời mời. Vui lòng thử lại.",
"members.inviteLimitExceeded": "Không đủ chỗ cho tất cả người nhận.",
"members.inviteTeamMember": "Mời thành viên nhóm",
"members.inviteTeamMemberTip": "Sau khi đăng nhập, họ có thể truy cập trực tiếp vào dữ liệu nhóm của bạn.",
"members.invitedAsRole": "Được mời với vai trò {{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "Chỉ có thể sử dụng ứng dụng, không thể xây dựng ứng dụng",
"members.ok": "OK",
"members.pending": "Đang chờ...",
"members.recipientCountExceedsSeats": "Bạn vẫn có thể gửi lời mời. Thành viên hiện có sẽ không chiếm thêm chỗ.",
"members.recipientCount_one": "{{count}} người nhận",
"members.recipientCount_other": "{{count}} người nhận",
"members.removeFromTeam": "Xóa khỏi nhóm",
"members.removeFromTeamConfirmDescription": "Xác nhận xóa thành viên này. Không thể hoàn tác thao tác này.",
"members.removeFromTeamConfirmTitle": "Xóa {{memberName}} khỏi nhóm",
"members.role": "VAI TRÒ",
"members.roles": "VAI TRÒ",
"members.seatsRemaining_one": "Còn {{count}} chỗ",
"members.seatsRemaining_other": "Còn {{count}} chỗ",
"members.selectRole": "Chọn một vai trò",
"members.sendInvite": "Gửi Lời mời",
"members.sendInviteCount_one": "Gửi {{count}} lời mời",
"members.sendInviteCount_other": "Gửi {{count}} lời mời",
"members.transferModal.codeLabel": "Mã xác thực",
"members.transferModal.codePlaceholder": "Dán mã 6 chữ số",
"members.transferModal.continue": "Tiếp tục",

View File

@ -228,12 +228,18 @@
"members.email": "邮箱",
"members.emailInvalid": "邮箱格式无效",
"members.emailNotSetup": "由于邮件服务器未设置,无法发送邀请邮件。请将邀请后生成的邀请链接通知用户。",
"members.emailPlaceholder": "输入邮箱",
"members.emailPlaceholder": "输入或粘贴邮箱地址…",
"members.emailRecipients": "邮箱地址",
"members.emailRecipientsTip": "粘贴邮箱列表,或按 Enter 逐个添加。",
"members.emailRequired": "请至少添加一个邮箱地址。",
"members.failedInvitationEmails": "邀请以下邮箱失败",
"members.invalidRole": "该角色已不可用,请重新选择。",
"members.invitationLink": "邀请链接",
"members.invitationSent": "邀请已发送",
"members.invitationSentTip": "邀请已发送,对方登录 Dify 后即可访问你的团队数据。",
"members.invite": "添加",
"members.inviteFailed": "邀请发送失败,请重试。",
"members.inviteLimitExceeded": "成员名额不足,无法发送这些邀请。",
"members.inviteTeamMember": "添加团队成员",
"members.inviteTeamMemberTip": "对方在登录后可以访问你的团队数据。",
"members.invitedAsRole": "邀请为{{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "只能使用应用程序,不能建立应用程序",
"members.ok": "好的",
"members.pending": "待定...",
"members.recipientCountExceedsSeats": "仍可提交,已是成员的邮箱不会重复占用名额。",
"members.recipientCount_one": "{{count}} 位收件人",
"members.recipientCount_other": "{{count}} 位收件人",
"members.removeFromTeam": "移出团队",
"members.removeFromTeamConfirmDescription": "确认将该成员移出,该操作不可逆",
"members.removeFromTeamConfirmTitle": "将{{memberName}}移出团队",
"members.role": "角色",
"members.roles": "角色",
"members.seatsRemaining_one": "剩余 {{count}} 个名额",
"members.seatsRemaining_other": "剩余 {{count}} 个名额",
"members.selectRole": "请选择角色",
"members.sendInvite": "发送邀请",
"members.sendInviteCount_one": "发送 {{count}} 个邀请",
"members.sendInviteCount_other": "发送 {{count}} 个邀请",
"members.transferModal.codeLabel": "验证码",
"members.transferModal.codePlaceholder": "输入 6 位数字验证码",
"members.transferModal.continue": "继续",

View File

@ -229,11 +229,17 @@
"members.emailInvalid": "郵箱格式無效",
"members.emailNotSetup": "由於郵件伺服器未設置,無法發送邀請郵件。請將邀請後生成的邀請連結通知用戶。",
"members.emailPlaceholder": "輸入郵箱",
"members.emailRecipients": "郵箱地址",
"members.emailRecipientsTip": "貼上郵箱列表,或按 Enter 逐個新增。",
"members.emailRequired": "請至少新增一個郵箱地址。",
"members.failedInvitationEmails": "邀請以下郵箱失敗",
"members.invalidRole": "該角色已不可用,請重新選擇。",
"members.invitationLink": "邀請連結",
"members.invitationSent": "邀請已傳送",
"members.invitationSentTip": "邀請已傳送,對方登入 Dify 後即可訪問你的團隊資料。",
"members.invite": "新增",
"members.inviteFailed": "邀請傳送失敗,請重試。",
"members.inviteLimitExceeded": "成員名額不足,無法傳送這些邀請。",
"members.inviteTeamMember": "新增團隊成員",
"members.inviteTeamMemberTip": "對方在登入後可以訪問你的團隊資料。",
"members.invitedAsRole": "邀請為{{role}}",
@ -254,13 +260,20 @@
"members.normalTip": "只能使用應用程式,不能建立應用程式",
"members.ok": "好的",
"members.pending": "待定...",
"members.recipientCountExceedsSeats": "仍可提交,已是成員的郵箱不會重複佔用名額。",
"members.recipientCount_one": "{{count}} 位收件人",
"members.recipientCount_other": "{{count}} 位收件人",
"members.removeFromTeam": "移出團隊",
"members.removeFromTeamConfirmDescription": "確認將該成員移出,此操作不可逆",
"members.removeFromTeamConfirmTitle": "將{{memberName}}移出團隊",
"members.role": "角色",
"members.roles": "角色",
"members.seatsRemaining_one": "剩餘 {{count}} 個名額",
"members.seatsRemaining_other": "剩餘 {{count}} 個名額",
"members.selectRole": "請選擇角色",
"members.sendInvite": "傳送邀請",
"members.sendInviteCount_one": "傳送 {{count}} 個邀請",
"members.sendInviteCount_other": "傳送 {{count}} 個邀請",
"members.transferModal.codeLabel": "驗證碼",
"members.transferModal.codePlaceholder": "請粘貼六位數代碼",
"members.transferModal.continue": "繼續",

View File

@ -130,27 +130,6 @@ export type FileUploadConfigResponse = {
file_upload_limit: number // default is 5
}
export type InvitationResult =
| {
status: 'success'
email: string
url: string
}
| {
status: 'already_member'
email: string
message?: string
}
| {
status: 'failed'
email: string
message: string
}
export type InvitationResponse = CommonResponse & {
invitation_results: InvitationResult[]
}
export type CodeBasedExtensionForm = {
type: string
label: I18nText

View File

@ -124,7 +124,6 @@
"react-dom": "catalog:",
"react-easy-crop": "catalog:",
"react-i18next": "catalog:",
"react-multi-email": "catalog:",
"react-papaparse": "catalog:",
"react-pdf-highlighter": "catalog:",
"react-sortablejs": "catalog:",

View File

@ -9,7 +9,6 @@ import type {
CommonResponse,
ICurrentWorkspace,
InitValidateStatusResponse,
InvitationResponse,
SetupStatusResponse,
} from '@/models/common'
import { del, get, patch, post } from './base'
@ -69,15 +68,6 @@ export const updateUserProfile = ({
return post<CommonResponse>(url, { body })
}
export const inviteMember = ({
url,
body,
}: {
url: string
body: Record<string, any>
}): Promise<InvitationResponse> => {
return post<InvitationResponse>(url, { body })
}
export const deleteMemberOrCancelInvitation = ({
url,
}: {