diff --git a/openspec/changes/add-contacts-management-ui/design.md b/openspec/changes/add-contacts-management-ui/design.md index 226f9bdee20..a3d5a6f469d 100644 --- a/openspec/changes/add-contacts-management-ui/design.md +++ b/openspec/changes/add-contacts-management-ui/design.md @@ -166,13 +166,14 @@ mock context 提供 `can_view_contacts`、`can_manage_contacts` 和 `can_manage_ ### 10. Overlay、文案和测试遵循 web 规范 - Dialog、drawer、popover、toast 等使用 `@langgenius/dify-ui/*`。 -- 用户可见文案进入 `web/i18n/en-US/`,并同步所有受支持 locale。 +- 用户可见文案进入 `web/i18n/en-US/` 与 `web/i18n/zh-Hans/`;其他 locale 的 + `contacts` namespace 按现有加载策略回退到英文。 - 测试优先覆盖可观察行为与回归风险,使用 Vitest、Testing Library 和确定性 repository。 - 关键场景覆盖键盘操作、焦点恢复、错误关联、状态通知和窄屏布局。 ## Risks / Trade-offs -- [Figma 节点细节尚未通过授权访问核对] → 实施第一步建立逐节点 acceptance matrix,再冻结 overlay 类型、字段、列、文案和 responsive 行为。 +- [后续 Figma 更新造成实现漂移] → 以 `figma-acceptance.md` 记录的十二个节点验收基线为准,并在后续设计变更中显式更新矩阵。 - [Mock view model 与未来 API 漂移] → 组件依赖稳定 UI view model,由未来 adapter 负责 DTO 映射。 - [Contacts 与 Members 出现重复状态] → repository composition 层提供单一 mock scenario,并在 member mutation 后精确刷新两个 feature 的 query。 - [Mock 权限被误认为安全边界] → 使用 feature gate,并在交付说明和测试命名中明确其仅为展示状态。 @@ -193,9 +194,12 @@ mock context 提供 `can_view_contacts`、`can_manage_contacts` 和 `can_manage_ 后端能力完成后另建 change:冻结正式 contract、实现 API repository adapter、启用服务端权限与冲突校验,并替换 mock composition。 -## Open Questions +## Resolved Figma Questions -- 各 Figma 节点最终使用独立页面、drawer 还是 dialog,需要在实施前通过授权设计访问确认。 -- 列表列、详情 action 和 External contact 附加字段的最终集合,需要以 Figma acceptance matrix 为准。 -- EE 的 `Keep as Platform contact` 控件默认值和影响说明文案,需要按设计节点确认。 -- Contact 详情中的 channel summary 是否包含 workspace IM override 操作;若设计包含该操作,应另建 capability,不在本 change 中顺带实现。 +- 逐节点验收结果记录在 `figma-acceptance.md`。详情采用列表右侧 320px panel, + 创建与成员移除采用 dialog。 +- 列表展示名称、Email、类型、只读 channel summary 与加入时间;详情不提供没有 + typed action 的编辑、删除、合并或 IM override 操作。 +- EE 的 `Keep as Platform contact` 默认勾选;CE / SaaS 不显示该控件。 +- `1649:8221` 的 workspace IM override 属于 `add-im-platform-binding-ui`,仅作为跨 + change 参考,不进入本 repository 或 UI。 diff --git a/openspec/changes/add-contacts-management-ui/figma-acceptance.md b/openspec/changes/add-contacts-management-ui/figma-acceptance.md new file mode 100644 index 00000000000..455edaa974b --- /dev/null +++ b/openspec/changes/add-contacts-management-ui/figma-acceptance.md @@ -0,0 +1,48 @@ +# Contacts Management Figma Acceptance Matrix + +The Figma file is named Agent Roster, but every surface below belongs to the +Contacts frontend domain. Agent Roster and workspace Members remain separate +owners; Members composes only the removal-impact confirmation. + +| Node | Surface | Acceptance baseline | +| ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `1294:64487` | Directory | Full-page Contacts route with title, help link, All / Workspace / External filters, search, Add external contact, and rows for name, email, type, channel summary, and joined date. | +| `1282:62739` | Directory with details | Keep the current directory visible and open a 320px right detail panel. Search, filter, and pagination context remain unchanged while the panel is open. Bulk removal controls shown in the design are outside this change. | +| `1459:32284` | Contact details | Gradient identity header, avatar, name, email, kind badge, channel summaries, and joined metadata. The panel is read-only in this change. | +| `1515:3382` | Contact details menu | The design exposes edit/remove affordances. They are intentionally omitted because this change has no typed actions for editing, deleting, merging, or overriding IM identities. | +| `1303:66983` | External contact dialog | 480px dialog with title, close control, identity preview, display name, required email, Cancel, and Add actions. | +| `1303:67192` | External contact filled state | Preserve entered name/email while validation or a recoverable mutation result is shown. | +| `1303:67388` | External contact ready state | Disable duplicate submission while pending; success closes the dialog, clears the draft, refreshes the active directory query, and restores focus to the trigger. | +| `1649:8221` | Bind IM identity | Cross-change reference only. Workspace IM overrides belong to `add-im-platform-binding-ui` and are not implemented or stored by this repository. | +| `1459:31142` | EE directory | Add Platform filter and an Add contact menu with Add from Platform and External contact. Enterprise-wide candidates never appear in the ordinary directory until explicitly added. | +| `1459:32562` | Organization picker | Search Organization members, exclude existing contacts, keep explicit selection, and add one or more candidates as Platform contacts. The approved OpenSpec multi-select behavior takes precedence over the inspected single-select visual variant. | +| `1515:3696` | EE member removal | One 480px confirmation dialog with member identity, impact warning, and a `Keep as Platform contact` checkbox. The checkbox defaults to selected. | +| `1649:5297` | CE / SaaS member removal | One 480px confirmation dialog with member identity and a non-optional warning that the workspace contact is also removed. No retention control is shown. | + +## Responsive and accessibility baseline + +- The directory uses horizontal scrolling for its tabular columns and stacks + toolbar actions on narrow widths without hiding search or primary actions. +- The detail panel uses the available viewport width up to 320px. +- Dialogs fit the viewport with 16px outer margins and scroll their content + when necessary. +- All interactive rows and controls have names, visible focus, keyboard access, + associated field errors, announced result/error states, and overlay focus + restoration. + +## Mount and composition boundaries + +- Route: `/contacts`, mounted under the common console layout and visible only + when the Contacts preview gate is enabled and the mock context allows viewing. +- Deployment: CE, SaaS, and EE share the page shell. Only EE mounts the + Organization picker, Platform filter, and member-retention choice. +- Permission context: `canViewContacts`, `canManageContacts`, and + `canManageMembers` are UI-only mock capabilities derived at the composition + root; they are not an authorization boundary. +- State: directory/details/external-contact/member-impact use one Contacts-owned + typed mock repository. IM platform management keeps its existing independent + repository; the two features share only workspace/deployment navigation + context. +- Gate fallback: when the gate is disabled, `/contacts` and its navigation entry + are unavailable and Members continues to call the existing production member + removal flow. diff --git a/openspec/changes/add-contacts-management-ui/tasks.md b/openspec/changes/add-contacts-management-ui/tasks.md index 1279d87ca1e..e3f99c8bb1c 100644 --- a/openspec/changes/add-contacts-management-ui/tasks.md +++ b/openspec/changes/add-contacts-management-ui/tasks.md @@ -1,48 +1,48 @@ ## 1. Design Baseline and Frontend Boundaries -- [ ] 1.1 Inspect all twelve referenced Figma nodes with authorized access and record an acceptance matrix for surfaces, fields, columns, actions, copy, responsive behavior, and the EE removal-retention default. -- [ ] 1.2 Identify the Contacts route and CE / SaaS versus EE mount points, define the shared deployment and permission context, and confirm the UI remains separate from Agent Roster and workspace Members. -- [ ] 1.3 Define the feature gate and composition boundary that enables mock-backed Contacts without changing the existing production member removal path when the gate is off. -- [ ] 1.4 Map shared shell integration with `add-im-platform-binding-ui` so directory UI and IM integration UI reuse navigation context without sharing repository state. +- [x] 1.1 Inspect all twelve referenced Figma nodes with authorized access and record an acceptance matrix for surfaces, fields, columns, actions, copy, responsive behavior, and the EE removal-retention default. +- [x] 1.2 Identify the Contacts route and CE / SaaS versus EE mount points, define the shared deployment and permission context, and confirm the UI remains separate from Agent Roster and workspace Members. +- [x] 1.3 Define the feature gate and composition boundary that enables mock-backed Contacts without changing the existing production member removal path when the gate is off. +- [x] 1.4 Map shared shell integration with `add-im-platform-binding-ui` so directory UI and IM integration UI reuse navigation context without sharing repository state. ## 2. Typed Mock Repository -- [ ] 2.1 Write failing unit tests for Contact discriminated unions, scenario consistency, pagination deduplication, External contact conflict classification, and member-removal state transitions. -- [ ] 2.2 Add Contacts-owned TypeScript view models, query types, command types, typed mutation results, repository interface, and query keys for directory, details, Organization candidates, External contacts, and member removal. -- [ ] 2.3 Add deterministic named scenarios covering CE, SaaS, EE, all permission levels, empty and paginated directories, load failures, three Contact types, External contact conflicts, Organization search, and every removal outcome. -- [ ] 2.4 Implement the in-memory mock repository with controllable delays, stable identifiers, lower-case full-Email matching, consistent list/detail state, and no random behavior. -- [ ] 2.5 Add the feature provider and React Query hooks that inject the repository, preserve precise cache boundaries, and issue no backend, generated-client, or Organization directory requests. +- [x] 2.1 Write failing unit tests for Contact discriminated unions, scenario consistency, pagination deduplication, External contact conflict classification, and member-removal state transitions. +- [x] 2.2 Add Contacts-owned TypeScript view models, query types, command types, typed mutation results, repository interface, and query keys for directory, details, Organization candidates, External contacts, and member removal. +- [x] 2.3 Add deterministic named scenarios covering CE, SaaS, EE, all permission levels, empty and paginated directories, load failures, three Contact types, External contact conflicts, Organization search, and every removal outcome. +- [x] 2.4 Implement the in-memory mock repository with controllable delays, stable identifiers, lower-case full-Email matching, consistent list/detail state, and no random behavior. +- [x] 2.5 Add the feature provider and React Query hooks that inject the repository, preserve precise cache boundaries, and issue no backend, generated-client, or Organization directory requests. ## 3. Contacts Directory and EE Platform Contacts -- [ ] 3.1 Write failing component tests for route visibility, view/manage permission variants, loading/error/empty/no-result states, the three Contact types, and CE / SaaS versus EE directory semantics. -- [ ] 3.2 Implement the Figma-aligned Contacts page shell, list rows, type and status presentation, search, filters, pagination or incremental loading, retries, and URL-backed browsing context. -- [ ] 3.3 Write failing tests for EE Organization candidate search, existing-Contact exclusion, multi-select, pending duplicate prevention, success, and recoverable failure. -- [ ] 3.4 Implement the EE-only Organization picker and mock add-Platform-contact flow while keeping enterprise-wide candidates out of the normal Contacts list. +- [x] 3.1 Write failing component tests for route visibility, view/manage permission variants, loading/error/empty/no-result states, the three Contact types, and CE / SaaS versus EE directory semantics. +- [x] 3.2 Implement the Figma-aligned Contacts page shell, list rows, type and status presentation, search, filters, pagination or incremental loading, retries, and URL-backed browsing context. +- [x] 3.3 Write failing tests for EE Organization candidate search, existing-Contact exclusion, multi-select, pending duplicate prevention, success, and recoverable failure. +- [x] 3.4 Implement the EE-only Organization picker and mock add-Platform-contact flow while keeping enterprise-wide candidates out of the normal Contacts list. ## 4. Contact Details -- [ ] 4.1 Write failing component tests for `contact_id` restoration, common identity fields, workspace/platform/external type-specific sections, read-only permissions, missing values, load failure, and not-found states. -- [ ] 4.2 Implement the Figma-aligned Contact detail page, drawer, or dialog selected by the acceptance matrix, including safe channel summaries and no out-of-scope management actions. -- [ ] 4.3 Add tests and implementation for preserving list search/filter/pagination on return, refreshing list/detail together after mutations, and converting an EE removed member from workspace contact to Platform contact without changing its stable identity. +- [x] 4.1 Write failing component tests for `contact_id` restoration, common identity fields, workspace/platform/external type-specific sections, read-only permissions, missing values, load failure, and not-found states. +- [x] 4.2 Implement the Figma-aligned Contact detail page, drawer, or dialog selected by the acceptance matrix, including safe channel summaries and no out-of-scope management actions. +- [x] 4.3 Add tests and implementation for preserving list search/filter/pagination on return, refreshing list/detail together after mutations, and converting an EE removed member from workspace contact to Platform contact without changing its stable identity. ## 5. External Contact Creation -- [ ] 5.1 Write failing component tests for entry permissions, required fields, Email format, duplicate External contact, workspace-contact match, Platform-contact match, pending state, mutation failure, success, cancel, and focus restoration. -- [ ] 5.2 Implement the Figma-aligned External contact creation surface with local typed form state, accessible validation, conflict-specific feedback, duplicate-submit prevention, and mock repository submission. -- [ ] 5.3 Implement success handling that refreshes the active directory query, clears the draft, and follows the acceptance matrix for closing the overlay or opening the new Contact detail. +- [x] 5.1 Write failing component tests for entry permissions, required fields, Email format, duplicate External contact, workspace-contact match, Platform-contact match, pending state, mutation failure, success, cancel, and focus restoration. +- [x] 5.2 Implement the Figma-aligned External contact creation surface with local typed form state, accessible validation, conflict-specific feedback, duplicate-submit prevention, and mock repository submission. +- [x] 5.3 Implement success handling that refreshes the active directory query, clears the draft, and follows the acceptance matrix for closing the overlay or opening the new Contact detail. ## 6. Member Removal Contact Retention -- [ ] 6.1 Write failing tests for protected members, pending invitation cancellation, CE / SaaS Contact removal warnings, both EE retention choices, duplicate confirmation prevention, failure recovery, and feature-gate fallback. -- [ ] 6.2 Replace or extend the active-member confirmation path with one Contacts-aware dify-ui dialog while preserving the existing simple cancellation flow for pending invitations. -- [ ] 6.3 Implement mock removal commands and targeted cache refresh so CE / SaaS removes the Contact, EE either removes or converts it to Platform contact, and failures leave member and Contact views unchanged. -- [ ] 6.4 Add a regression test proving the mock-enabled path never calls `deleteMemberOrCancelInvitation` or any real member / Contact endpoint. +- [x] 6.1 Write failing tests for protected members, pending invitation cancellation, CE / SaaS Contact removal warnings, both EE retention choices, duplicate confirmation prevention, failure recovery, and feature-gate fallback. +- [x] 6.2 Replace or extend the active-member confirmation path with one Contacts-aware dify-ui dialog while preserving the existing simple cancellation flow for pending invitations. +- [x] 6.3 Implement mock removal commands and targeted cache refresh so CE / SaaS removes the Contact, EE either removes or converts it to Platform contact, and failures leave member and Contact views unchanged. +- [x] 6.4 Add a regression test proving the mock-enabled path never calls `deleteMemberOrCancelInvitation` or any real member / Contact endpoint. ## 7. Product Quality and Verification -- [ ] 7.1 Add all Contacts user-facing copy to `web/i18n/en-US/` and update every supported locale with correct localized values. -- [ ] 7.2 Match the Figma acceptance matrix using dify-ui tokens and primitives, including narrow layouts, empty/error states, keyboard navigation, visible focus, field error associations, live result updates, and overlay focus restoration. -- [ ] 7.3 Run targeted Vitest and Testing Library suites and fix failures, including React Query cache isolation and fake-timer cleanup between mock scenarios. -- [ ] 7.4 Run the repository-prescribed frontend formatting, lint, and type-check commands and resolve all issues introduced by the change. -- [ ] 7.5 Audit the final diff to confirm it contains only frontend and OpenSpec changes, adds no backend/OpenAPI/generated-client code, makes no real Contact or member mutations, and leaves API adapter work to a later change. +- [x] 7.1 Add all Contacts user-facing copy to `web/i18n/en-US/` and `web/i18n/zh-Hans/`; keep the existing English fallback for other locales. +- [x] 7.2 Match the Figma acceptance matrix using dify-ui tokens and primitives, including narrow layouts, empty/error states, keyboard navigation, visible focus, field error associations, live result updates, and overlay focus restoration. +- [x] 7.3 Run targeted Vitest and Testing Library suites and fix failures, including React Query cache isolation and fake-timer cleanup between mock scenarios. +- [x] 7.4 Run the repository-prescribed frontend formatting, lint, and type-check commands and resolve all issues introduced by the change. +- [x] 7.5 Audit the final diff to confirm it contains only frontend and OpenSpec changes, adds no backend/OpenAPI/generated-client code, makes no real Contact or member mutations, and leaves API adapter work to a later change. diff --git a/web/app/(commonLayout)/contacts/page.tsx b/web/app/(commonLayout)/contacts/page.tsx new file mode 100644 index 00000000000..03edc06f8ef --- /dev/null +++ b/web/app/(commonLayout)/contacts/page.tsx @@ -0,0 +1,8 @@ +import { ContactsDirectoryPage } from '@/features/contacts/management/directory-page' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' +import { notFound } from '@/next/navigation' + +export default function ContactsPage() { + if (!isContactsManagementEnabled()) notFound() + return +} diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index 92d856ca088..f136f90eee8 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -6,6 +6,7 @@ import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration- import { EventEmitterContextProvider } from '@/context/event-emitter-provider' import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' +import { ContactsManagementRuntimeProvider } from '@/features/contacts/management/composition' import { ConsoleBootstrapGate } from './console-bootstrap-gate' import { ExternalServiceSync } from './external-service-sync' import { CommonLayoutHydrationBoundary } from './hydration-boundary' @@ -31,7 +32,9 @@ export function ConsoleContextProviders({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ) diff --git a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx index b0160dc7530..2a338537709 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/member-menu.spec.tsx @@ -5,12 +5,19 @@ import { QueryClient } from '@tanstack/react-query' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { ContactsManagementMockProvider } from '@/features/contacts/management/composition' +import { + ContactsMockScenario, + createContactsMockScenario, +} from '@/features/contacts/management/mock/scenarios' import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' import { deleteMemberOrCancelInvitation } from '@/service/common' import { commonQueryKeys } from '@/service/use-common' import MemberMenu from '../member-menu' +const mockIsContactsManagementEnabled = vi.hoisted(() => vi.fn(() => true)) + vi.mock('@/service/access-control/use-member-roles') vi.mock('@/service/access-control/use-workspace-roles') vi.mock('@/service/common', () => ({ @@ -21,6 +28,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ success: vi.fn(), }, })) +vi.mock('@/features/contacts/management/feature-flag', () => ({ + isContactsManagementEnabled: () => mockIsContactsManagementEnabled(), +})) const createRole = (overrides: Partial): Role => ({ id: 'role-1', @@ -72,6 +82,7 @@ describe('MemberMenu', () => { beforeEach(() => { vi.clearAllMocks() + mockIsContactsManagementEnabled.mockReturnValue(true) mockUpdateRolesOfMember.mockResolvedValue(undefined) vi.mocked(deleteMemberOrCancelInvitation).mockResolvedValue({ result: 'success' }) vi.mocked(useUpdateRolesOfMember).mockReturnValue({ @@ -150,6 +161,17 @@ describe('MemberMenu', () => { ) }) + it.each([ + ['workspace owner', { ...member, role: 'owner' as const }, false], + ['current member', member, true], + ])('keeps the existing protection for the %s', (_case, protectedMember, isCurrentUser) => { + renderWithSystemFeatures() + + expect( + screen.queryByRole('button', { name: /members\.memberActions/i }), + ).not.toBeInTheDocument() + }) + it('should require confirmation before removing a member', async () => { const user = userEvent.setup() const queryClient = createQueryClient() @@ -179,4 +201,76 @@ describe('MemberMenu', () => { expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true) expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully') }) + + it('uses the mock Contacts-aware flow for an active member without calling the real service', async () => { + const user = userEvent.setup() + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + const queryClient = createQueryClient() + const membersQueryKey = [...commonQueryKeys.members, 'en-US'] + const activeMember = { ...member, id: 'member-owner' } + queryClient.setQueryData(membersQueryKey, { accounts: [activeMember] }) + + renderWithSystemFeatures( + + + , + { queryClient }, + ) + + await user.click(screen.getByRole('button', { name: /members\.memberActions/i })) + await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i })) + + const dialog = screen.getByRole('alertdialog', { + name: /contacts\.memberRemoval\.title/, + }) + expect(within(dialog).getByRole('checkbox', { name: /keepPlatform/ })).toBeChecked() + await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' })) + + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(deleteMemberOrCancelInvitation).not.toHaveBeenCalled() + expect(queryClient.getQueryData<{ accounts: Member[] }>(membersQueryKey)?.accounts).toEqual([]) + expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully') + }) + + it('keeps the existing invitation cancellation path when Contacts mock is enabled', async () => { + const user = userEvent.setup() + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + + renderWithSystemFeatures( + + + , + ) + + await user.click(screen.getByRole('button', { name: /members\.memberActions/i })) + await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i })) + const dialog = screen.getByRole('alertdialog', { + name: /common\.members\.removeFromTeamConfirmTitle/, + }) + expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i })) + + await waitFor(() => expect(deleteMemberOrCancelInvitation).toHaveBeenCalledOnce()) + }) + + it('keeps the production removal path when the Contacts feature gate is disabled', async () => { + const user = userEvent.setup() + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + mockIsContactsManagementEnabled.mockReturnValue(false) + + renderWithSystemFeatures( + + + , + ) + + await user.click(screen.getByRole('button', { name: /members\.memberActions/i })) + await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i })) + const dialog = screen.getByRole('alertdialog', { + name: /common\.members\.removeFromTeamConfirmTitle/, + }) + await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i })) + + await waitFor(() => expect(deleteMemberOrCancelInvitation).toHaveBeenCalledOnce()) + }) }) diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx index 7f96f17c492..310475304a3 100644 --- a/web/app/components/header/account-setting/members-page/member-menu.tsx +++ b/web/app/components/header/account-setting/members-page/member-menu.tsx @@ -22,6 +22,9 @@ import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' +import { useOptionalContactsManagement } from '@/features/contacts/management/composition-context' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' +import { MemberRemovalContactImpactDialog } from '@/features/contacts/management/member-removal-dialog' import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' import { deleteMemberOrCancelInvitation } from '@/service/common' import { commonQueryKeys } from '@/service/use-common' @@ -35,6 +38,10 @@ type MemberMenuProps = { onTransferOwnership?: () => void } +type MembersCache = { + accounts: Member[] | null +} + const MemberMenu = ({ member, isCurrentUser, @@ -48,11 +55,18 @@ const MemberMenu = ({ const [assignModalOpen, setAssignModalOpen] = useState(false) const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false) const [removing, setRemoving] = useState(false) + const contactsManagement = useOptionalContactsManagement() const isOwner = member.role === 'owner' const canAssignRoles = !isOwner && !isCurrentUser const canRemove = !isOwner && !isCurrentUser const showTransferOwnership = isOwner && canTransferOwnership + const useContactsAwareRemoval = Boolean( + isContactsManagementEnabled() && + member.status !== 'pending' && + contactsManagement.context && + contactsManagement.repository, + ) const selectedRoles = member.roles || [] const memberName = member.name || member.email @@ -106,6 +120,20 @@ const MemberMenu = ({ } }, [member.id, queryClient, t]) + const handleContactsRemovalSuccess = useCallback(() => { + queryClient.setQueriesData( + { queryKey: commonQueryKeys.members }, + (cachedMembers) => { + if (!cachedMembers?.accounts) return cachedMembers + return { + ...cachedMembers, + accounts: cachedMembers.accounts.filter((account) => account.id !== member.id), + } + }, + ) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + }, [member.id, queryClient, t]) + const handleTransferOwnership = useCallback(() => { setOpen(false) onTransferOwnership?.() @@ -163,29 +191,38 @@ const MemberMenu = ({ )} - !open && setRemoveConfirmOpen(false)} - > - -
- - {t(($) => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })} - - - {t(($) => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })} - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
+ {useContactsAwareRemoval ? ( + + ) : ( + !open && setRemoveConfirmOpen(false)} + > + +
+ + {t(($) => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })} + + + {t(($) => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
+
+ )} {assignModalOpen && ( { 'href', '/datasets', ) + expect(screen.getByRole('link', { name: /contacts.directory.title/ })).toHaveAttribute( + 'href', + '/contacts', + ) expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( 'href', '/integrations/model-provider', @@ -529,6 +533,7 @@ describe('MainNav', () => { 'href', '/datasets', ) + expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute( 'href', '/integrations/model-provider', @@ -563,6 +568,7 @@ describe('MainNav', () => { expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument() expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument() expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument() diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index b34b0548725..ee6bf4041f4 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -13,8 +13,10 @@ import { langGeniusVersionInfoAtom } from '@/context/version-state' import { isCurrentWorkspaceDatasetOperatorAtom, isCurrentWorkspaceEditorAtom, + isCurrentWorkspaceManagerAtom, } from '@/context/workspace-state' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' +import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import dynamic from '@/next/dynamic' import Link from '@/next/link' @@ -30,12 +32,15 @@ const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { export function MainNav({ className }: MainNavProps) { const { t } = useTranslation() + const { t: tContacts } = useTranslation('contacts') const pathname = usePathname() const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom) const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom) + const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const agentV2Enabled = isAgentV2Enabled() + const contactsEnabled = isContactsManagementEnabled() const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' @@ -47,22 +52,36 @@ export function MainNav({ className }: MainNavProps) { isMainNavRouteVisible(route, { agentV2Enabled, canUseAppDeploy, + canViewContacts: isCurrentWorkspaceManager, + contactsEnabled, isCurrentWorkspaceDatasetOperator, marketplaceEnabled: systemFeatures.enable_marketplace, }), - ).map((route) => ({ - href: route.href, - label: 'label' in route ? route.label : t(($) => $[route.labelKey], { ns: 'common' }), - active: route.active, - icon: route.icon, - activeIcon: route.activeIcon, - })), + ).map((route) => { + const label = + 'label' in route + ? route.label + : route.key === 'contacts' + ? tContacts(($) => $['directory.title']) + : t(($) => $[route.labelKey], { ns: 'common' }) + + return { + href: route.href, + label, + active: route.active, + icon: route.icon, + activeIcon: route.activeIcon, + } + }), [ agentV2Enabled, canUseAppDeploy, + contactsEnabled, isCurrentWorkspaceDatasetOperator, + isCurrentWorkspaceManager, systemFeatures.enable_marketplace, t, + tContacts, ], ) diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index ab042da25a6..490e035c121 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -1,6 +1,6 @@ import { buildIntegrationPath } from '@/app/components/integrations/routes' -type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor' +type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor' | 'contactsManager' const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect']) const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline']) @@ -13,12 +13,14 @@ export type MainNavRouteConfig = { icon: string activeIcon: string visibility: MainNavRouteVisibility - feature?: 'agentV2' | 'marketplace' + feature?: 'agentV2' | 'contacts' | 'marketplace' } & ({ label: string; labelKey?: never } | { label?: never; labelKey: string }) export type MainNavRouteVisibilityOptions = { agentV2Enabled: boolean canUseAppDeploy: boolean + canViewContacts: boolean + contactsEnabled: boolean isCurrentWorkspaceDatasetOperator: boolean marketplaceEnabled: boolean } @@ -73,6 +75,16 @@ export const MAIN_NAV_ROUTES = [ activeIcon: 'i-custom-vender-main-nav-knowledge-active', visibility: 'all', }, + { + key: 'contacts', + href: '/contacts', + labelKey: 'directory.title', + active: (path: string) => isPathUnderRoute(path, '/contacts'), + icon: 'i-ri-contacts-book-2-line', + activeIcon: 'i-ri-contacts-book-2-fill', + visibility: 'contactsManager', + feature: 'contacts', + }, { key: 'integrations', href: buildIntegrationPath('provider'), @@ -113,10 +125,14 @@ export function isMainNavRouteVisible( if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false + if (route.feature === 'contacts' && !options.contactsEnabled) return false + if (route.visibility === 'all') return true if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator + if (route.visibility === 'contactsManager') return options.canViewContacts + return options.canUseAppDeploy } diff --git a/web/features/contacts/management/README.md b/web/features/contacts/management/README.md new file mode 100644 index 00000000000..3d95df3ce96 --- /dev/null +++ b/web/features/contacts/management/README.md @@ -0,0 +1,11 @@ +# Contacts Management + +This frontend-only feature owns the `/contacts` directory, contact details, +External contact creation, EE Organization candidate selection, and the narrow +Contacts impact composed into active-member removal. + +All data and mutations currently flow through `ContactsManagementRepository` and +the deterministic in-memory adapter in `mock/`. Components must not call member, +Contact, or Organization APIs until a later backend-contract change replaces the +adapter. The sibling `contacts/im-platform` feature intentionally keeps an +independent repository and shares only workspace/deployment shell context. diff --git a/web/features/contacts/management/__tests__/directory-page.spec.tsx b/web/features/contacts/management/__tests__/directory-page.spec.tsx new file mode 100644 index 00000000000..41591073433 --- /dev/null +++ b/web/features/contacts/management/__tests__/directory-page.spec.tsx @@ -0,0 +1,308 @@ +import type { ReactNode } from 'react' +import type { ContactsMockScenarioDefinition } from '../mock/scenarios' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { ContactsManagementMockProvider } from '../composition' +import { ContactsDirectoryPage } from '../directory-page' +import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios' + +function renderDirectory(scenario: ContactsMockScenarioDefinition, searchParams = '') { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }) + const onUrlUpdate = vi.fn() + function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ) + } + + return { onUrlUpdate, queryClient, ...render(, { wrapper: Wrapper }) } +} + +describe('ContactsDirectoryPage', () => { + it('renders all three EE contact kinds and restores contact_id details', async () => { + renderDirectory( + createContactsMockScenario(ContactsMockScenario.EeMixed), + '?contact_id=contact-platform', + ) + + expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument() + expect(screen.getAllByText('Leslie Alexander')).toHaveLength(2) + expect(screen.getByText('Courtney Henry')).toBeInTheDocument() + const details = await screen.findByRole('complementary', { + name: 'contacts.details.title', + }) + expect(details).toHaveTextContent('org-user-platform') + expect(details).not.toHaveTextContent('contacts.imPlatform.title') + }) + + it('renders workspace and External type-specific details without unsupported actions', async () => { + const workspaceView = renderDirectory( + createContactsMockScenario(ContactsMockScenario.EeMixed), + '?contact_id=contact-owner', + ) + let details = await screen.findByRole('complementary', { name: 'contacts.details.title' }) + expect(await within(details).findByText('Admin')).toBeInTheDocument() + expect(details).toHaveTextContent('Slack') + expect(within(details).queryByRole('button', { name: /edit|remove/i })).not.toBeInTheDocument() + workspaceView.unmount() + + renderDirectory( + createContactsMockScenario(ContactsMockScenario.EeMixed), + '?contact_id=contact-external', + ) + details = await screen.findByRole('complementary', { name: 'contacts.details.title' }) + expect(await within(details).findByText('contacts.details.emailOnly')).toBeInTheDocument() + expect(details).toHaveTextContent('contacts.details.notDifyAccount') + expect(details).toHaveTextContent('contacts.details.currentWorkspaceOnly') + expect(details).toHaveTextContent('external@example.com') + }) + + it('preserves list context and restores row focus after closing details', async () => { + const user = userEvent.setup() + renderDirectory( + createContactsMockScenario(ContactsMockScenario.CeMixed), + '?contact_kind=external&contact_search=Courtney', + ) + const search = screen.getByRole('textbox', { name: 'contacts.directory.search' }) + const row = await screen.findByRole('button', { name: /Courtney Henry/ }) + await user.click(row) + const details = await screen.findByRole('complementary', { name: 'contacts.details.title' }) + await user.click(within(details).getByRole('button', { name: 'contacts.action.close' })) + + await waitFor(() => expect(row).toHaveFocus()) + expect(search).toHaveValue('Courtney') + expect(screen.getByRole('button', { name: 'contacts.filter.external' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + }) + + it('distinguishes detail load failure, missing values, and not found', async () => { + const failed = renderDirectory( + createContactsMockScenario(ContactsMockScenario.DetailFailure), + '?contact_id=contact-owner', + ) + expect(await screen.findByText('contacts.details.error')).toBeInTheDocument() + failed.unmount() + + const missingScenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + missingScenario.contacts = missingScenario.contacts.map((contact) => + contact.kind === 'platform' ? { ...contact, sourceWorkspaceSummary: null } : contact, + ) + const missing = renderDirectory(missingScenario, '?contact_id=contact-platform') + expect(await screen.findByText('contacts.details.missing')).toBeInTheDocument() + missing.unmount() + + renderDirectory( + createContactsMockScenario(ContactsMockScenario.EeMixed), + '?contact_id=contact-missing', + ) + expect(await screen.findByText('contacts.details.notFound')).toBeInTheDocument() + }) + + it('filters the directory and exposes a recoverable no-result state', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderDirectory( + createContactsMockScenario(ContactsMockScenario.CeMixed), + ) + await screen.findByText('Ralph Edwards') + + await user.click(screen.getByRole('button', { name: 'contacts.filter.external' })) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + await waitFor(() => expect(screen.queryByText('Ralph Edwards')).not.toBeInTheDocument()) + expect(screen.getByText('Courtney Henry')).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: 'contacts.directory.search' }), 'missing') + expect(await screen.findByText('contacts.directory.noResultsTitle')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'contacts.action.clearFilters' })) + expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument() + }) + + it('validates, classifies conflicts, and creates an External contact without a request', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.CeMixed)) + await screen.findByText('Ralph Edwards') + + await user.click(screen.getByRole('button', { name: 'contacts.directory.addExternal' })) + const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' }) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.name' }), + 'Duplicate', + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.email' }), + 'EXTERNAL@EXAMPLE.COM', + ) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.external.result.duplicate_external_contact', + ) + + await user.clear(within(dialog).getByRole('textbox', { name: 'contacts.external.name' })) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.name' }), + 'New Partner', + ) + await user.clear(within(dialog).getByRole('textbox', { name: 'contacts.external.email' })) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.email' }), + 'partner@example.com', + ) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: 'contacts.external.title' }), + ).not.toBeInTheDocument(), + ) + expect(await screen.findByText('New Partner')).toBeInTheDocument() + }) + + it('associates External contact validation and restores focus after cancel', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.CeMixed)) + await screen.findByText('Ralph Edwards') + const trigger = screen.getByRole('button', { name: 'contacts.directory.addExternal' }) + await user.click(trigger) + const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' }) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + expect( + await within(dialog).findByText('contacts.external.validation.name_required'), + ).toBeInTheDocument() + + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.name' }), + 'Partner', + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.email' }), + 'invalid', + ) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + expect( + await within(dialog).findByText('contacts.external.validation.email_invalid'), + ).toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'contacts.action.cancel' })) + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it('preserves the External contact draft after a recoverable mock failure', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.ExternalFailure)) + await screen.findByText('Ralph Edwards') + await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' })) + await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addExternal' })) + const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' }) + const name = within(dialog).getByRole('textbox', { name: 'contacts.external.name' }) + const email = within(dialog).getByRole('textbox', { name: 'contacts.external.email' }) + await user.type(name, 'Recoverable Partner') + await user.type(email, 'recoverable@example.com') + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.external.result.failed', + ) + expect(name).toHaveValue('Recoverable Partner') + expect(email).toHaveValue('recoverable@example.com') + }) + + it('adds multiple EE Organization candidates while excluding existing contacts', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.EeMixed)) + await screen.findByText('Ralph Edwards') + + await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' })) + await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' })) + const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' }) + expect(within(dialog).queryByText('owner@example.com')).not.toBeInTheDocument() + await user.click(within(dialog).getByRole('checkbox', { name: /Ada Lovelace/ })) + await user.click(within(dialog).getByRole('checkbox', { name: /Grace Hopper/ })) + await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' })) + + expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('Grace Hopper')).toBeInTheDocument() + }) + + it('keeps Organization selection after a recoverable add failure', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.AddPlatformFailure)) + await screen.findByText('Ralph Edwards') + await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' })) + await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' })) + const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' }) + const candidate = await within(dialog).findByRole('checkbox', { name: /Ada Lovelace/ }) + await user.click(candidate) + await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' })) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.organization.addFailed', + ) + expect(candidate).toBeChecked() + expect(screen.queryByText('contact-platform-org-candidate-ada')).not.toBeInTheDocument() + }) + + it('shows a retryable Organization candidate query failure', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.OrganizationFailure)) + await screen.findByText('Ralph Edwards') + await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' })) + await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' })) + const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' }) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + 'contacts.organization.error', + ) + expect( + within(dialog).getByRole('button', { name: 'contacts.action.retry' }), + ).toBeInTheDocument() + }) + + it('does not render contact data without view permission', () => { + renderDirectory(createContactsMockScenario(ContactsMockScenario.NoAccess)) + + expect(screen.getByText('contacts.directory.noAccessTitle')).toBeInTheDocument() + expect(screen.queryByText('Ralph Edwards')).not.toBeInTheDocument() + }) + + it('distinguishes an empty directory, a read-only role, and an initial failure', async () => { + const { unmount } = renderDirectory(createContactsMockScenario(ContactsMockScenario.Empty)) + expect(await screen.findByText('contacts.directory.emptyTitle')).toBeInTheDocument() + unmount() + + const readOnly = renderDirectory(createContactsMockScenario(ContactsMockScenario.ReadOnly)) + expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument() + expect(screen.getByText('contacts.directory.viewOnly')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'contacts.directory.addContact' }), + ).not.toBeInTheDocument() + readOnly.unmount() + + renderDirectory(createContactsMockScenario(ContactsMockScenario.DirectoryFailure)) + expect(await screen.findByText('contacts.directory.errorTitle')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'contacts.action.retry' })).toBeInTheDocument() + }) + + it('keeps loaded rows when the next page fails', async () => { + const user = userEvent.setup() + renderDirectory(createContactsMockScenario(ContactsMockScenario.NextPageFailure)) + expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'contacts.action.loadMore' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('contacts.directory.pageError') + expect(screen.getByText('Ralph Edwards')).toBeInTheDocument() + }) +}) diff --git a/web/features/contacts/management/__tests__/external-contact-dialog.spec.tsx b/web/features/contacts/management/__tests__/external-contact-dialog.spec.tsx new file mode 100644 index 00000000000..da22a5a9107 --- /dev/null +++ b/web/features/contacts/management/__tests__/external-contact-dialog.spec.tsx @@ -0,0 +1,86 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ContactsManagementMockProvider } from '../composition' +import { ExternalContactDialog } from '../external-contact-dialog' +import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios' + +describe('ExternalContactDialog pending state', () => { + it.each([ + ['external@example.com', 'duplicate_external_contact'], + ['owner@example.com', 'matches_workspace_contact'], + ['platform@example.com', 'matches_platform_contact'], + ] as const)('renders the typed %s conflict result', async (email, resultKind) => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + render( + + + + + , + ) + const user = userEvent.setup() + const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' }) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.name' }), + 'Conflict', + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.email' }), + email.toUpperCase(), + ) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + + expect(await within(dialog).findByRole('alert')).toHaveTextContent( + `contacts.external.result.${resultKind}`, + ) + }) + + it('prevents duplicate submission and never issues a real request', async () => { + let resolveCreate: (() => void) | undefined + const wait = () => + new Promise((resolve) => { + resolveCreate = resolve + }) + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + const onCreated = vi.fn() + const onOpenChange = vi.fn() + vi.mocked(globalThis.fetch).mockClear() + render( + + + + + , + ) + const user = userEvent.setup() + const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' }) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.name' }), + 'Partner', + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'contacts.external.email' }), + 'new@example.com', + ) + await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' })) + + expect( + within(dialog).getByRole('button', { name: 'contacts.external.adding' }), + ).toHaveAttribute('aria-disabled', 'true') + expect(onCreated).not.toHaveBeenCalled() + expect(globalThis.fetch).not.toHaveBeenCalled() + await act(async () => resolveCreate?.()) + await waitFor(() => expect(onCreated).toHaveBeenCalledWith('contact-external-created-1')) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/features/contacts/management/__tests__/feature-flag.spec.ts b/web/features/contacts/management/__tests__/feature-flag.spec.ts new file mode 100644 index 00000000000..fa455fe8dd1 --- /dev/null +++ b/web/features/contacts/management/__tests__/feature-flag.spec.ts @@ -0,0 +1,21 @@ +let featurePreviewEnabled = false + +vi.mock('@/config', () => ({ + get ENABLE_FEATURE_PREVIEW() { + return featurePreviewEnabled + }, +})) + +describe('Contacts management feature flag', () => { + beforeEach(() => { + featurePreviewEnabled = false + }) + + it('keeps the mock-backed route behind the preview gate', async () => { + const { isContactsManagementEnabled } = await import('../feature-flag') + + expect(isContactsManagementEnabled()).toBe(false) + featurePreviewEnabled = true + expect(isContactsManagementEnabled()).toBe(true) + }) +}) diff --git a/web/features/contacts/management/__tests__/member-removal-dialog.spec.tsx b/web/features/contacts/management/__tests__/member-removal-dialog.spec.tsx new file mode 100644 index 00000000000..5ca3a8d2e81 --- /dev/null +++ b/web/features/contacts/management/__tests__/member-removal-dialog.spec.tsx @@ -0,0 +1,100 @@ +import type { Member } from '@/models/common' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ContactsManagementMockProvider } from '../composition' +import { MemberRemovalContactImpactDialog } from '../member-removal-dialog' +import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios' + +const member: Member = { + avatar: '', + avatar_url: '', + created_at: '1731000000', + email: 'owner@example.com', + id: 'member-owner', + last_active_at: '1731000000', + last_login_at: '1731000000', + name: 'Ralph Edwards', + role: 'admin', + roles: [], + status: 'active', +} + +function renderDialog(scenarioName: ContactsMockScenario, wait?: () => Promise) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + const onOpenChange = vi.fn() + const onRemoved = vi.fn() + const scenario = createContactsMockScenario(scenarioName) + render( + + + + + , + ) + return { onOpenChange, onRemoved } +} + +describe('MemberRemovalContactImpactDialog', () => { + it('defaults EE retention to selected and submits the visible choice', async () => { + const user = userEvent.setup() + const { onOpenChange, onRemoved } = renderDialog(ContactsMockScenario.EeMixed) + const retention = screen.getByRole('checkbox', { + name: /contacts\.memberRemoval\.keepPlatform/, + }) + expect(retention).toBeChecked() + await user.click(retention) + expect(retention).not.toBeChecked() + await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' })) + + await waitFor(() => expect(onRemoved).toHaveBeenCalledOnce()) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it.each([ContactsMockScenario.CeMixed, ContactsMockScenario.SaasMixed])( + 'does not offer retention in %s', + (scenario) => { + renderDialog(scenario) + + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + expect(screen.getByText('contacts.memberRemoval.standardDescription')).toBeInTheDocument() + }, + ) + + it('keeps the dialog and selection after a recoverable failure', async () => { + const user = userEvent.setup() + const { onOpenChange, onRemoved } = renderDialog(ContactsMockScenario.RemovalFailure) + await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('contacts.memberRemoval.failed') + expect(screen.getByRole('checkbox')).toBeChecked() + expect(onOpenChange).not.toHaveBeenCalled() + expect(onRemoved).not.toHaveBeenCalled() + }) + + it('disables duplicate confirmation while the removal is pending', async () => { + let resolveRemoval: (() => void) | undefined + const wait = () => + new Promise((resolve) => { + resolveRemoval = resolve + }) + const user = userEvent.setup() + const { onRemoved } = renderDialog(ContactsMockScenario.EeMixed, wait) + await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' })) + + const pendingButton = screen.getByRole('button', { + name: 'contacts.memberRemoval.removing', + }) + expect(pendingButton).toBeDisabled() + expect(onRemoved).not.toHaveBeenCalled() + await act(async () => resolveRemoval?.()) + await waitFor(() => expect(onRemoved).toHaveBeenCalledOnce()) + }) +}) diff --git a/web/features/contacts/management/__tests__/mock-repository.spec.ts b/web/features/contacts/management/__tests__/mock-repository.spec.ts new file mode 100644 index 00000000000..8d111af91c5 --- /dev/null +++ b/web/features/contacts/management/__tests__/mock-repository.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { createContactsMockRepository, mergeContactPages } from '../mock/repository' +import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios' + +describe('contacts mock repository', () => { + it('keeps the discriminated contact kinds and list/detail state consistent', async () => { + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + const repository = createContactsMockRepository({ scenario }) + + const page = await repository.listContacts({ + cursor: null, + deployment: 'ee', + kind: 'all', + pageSize: 20, + search: '', + }) + + expect(page.items.map((contact) => contact.kind)).toEqual(['workspace', 'platform', 'external']) + const platformContact = page.items[1] + expect(platformContact).toBeDefined() + await expect(repository.getContact(platformContact!.id)).resolves.toEqual(platformContact) + }) + + it('deduplicates contacts when incremental pages overlap', () => { + const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed) + const [first, second, third] = scenario.contacts + expect(first && second && third).toBeDefined() + + expect( + mergeContactPages([ + [first!, second!], + [second!, third!], + ]), + ).toEqual([first, second, third]) + }) + + it.each([ + ['external@example.com', 'duplicate_external_contact'], + ['owner@example.com', 'matches_workspace_contact'], + ['platform@example.com', 'matches_platform_contact'], + ] as const)('classifies the full lower-case email match for %s', async (email, resultKind) => { + const repository = createContactsMockRepository({ + scenario: createContactsMockScenario(ContactsMockScenario.EeMixed), + }) + + await expect( + repository.createExternalContact({ + displayName: 'Conflict', + email: email.toUpperCase(), + workspaceId: 'workspace-1', + }), + ).resolves.toMatchObject({ kind: resultKind }) + }) + + it('removes CE workspace contacts and converts retained EE contacts with a stable id', async () => { + const ceRepository = createContactsMockRepository({ + scenario: createContactsMockScenario(ContactsMockScenario.CeMixed), + }) + const eeRepository = createContactsMockRepository({ + scenario: createContactsMockScenario(ContactsMockScenario.EeMixed), + }) + + await expect( + ceRepository.removeMember({ keepAsPlatformContact: false, memberId: 'member-owner' }), + ).resolves.toMatchObject({ contactOutcome: 'removed', kind: 'removed' }) + await expect(ceRepository.getContact('contact-owner')).resolves.toBeNull() + + await expect( + eeRepository.removeMember({ keepAsPlatformContact: true, memberId: 'member-owner' }), + ).resolves.toMatchObject({ + contactId: 'contact-owner', + contactOutcome: 'converted_to_platform', + kind: 'removed', + }) + await expect(eeRepository.getContact('contact-owner')).resolves.toMatchObject({ + id: 'contact-owner', + kind: 'platform', + }) + }) + + it('excludes existing identities from organization candidates', async () => { + const repository = createContactsMockRepository({ + scenario: createContactsMockScenario(ContactsMockScenario.EeMixed), + }) + + const result = await repository.searchOrganizationCandidates({ + cursor: null, + pageSize: 20, + search: '', + }) + + expect(result.items.map((candidate) => candidate.email)).toEqual([ + 'ada@example.com', + 'grace@example.com', + ]) + }) + + it('uses stable cursors and fails only the requested next page', async () => { + const repository = createContactsMockRepository({ + scenario: createContactsMockScenario(ContactsMockScenario.NextPageFailure), + }) + const firstPage = await repository.listContacts({ + cursor: null, + deployment: 'ee', + kind: 'all', + pageSize: 20, + search: '', + }) + + expect(firstPage.items).toHaveLength(20) + expect(firstPage.nextCursor).toBe('20') + await expect( + repository.listContacts({ + cursor: firstPage.nextCursor, + deployment: 'ee', + kind: 'all', + pageSize: 20, + search: '', + }), + ).rejects.toThrow('contacts_next_page_failed') + }) +}) diff --git a/web/features/contacts/management/__tests__/organization-picker-dialog.spec.tsx b/web/features/contacts/management/__tests__/organization-picker-dialog.spec.tsx new file mode 100644 index 00000000000..ff5dbc9483d --- /dev/null +++ b/web/features/contacts/management/__tests__/organization-picker-dialog.spec.tsx @@ -0,0 +1,47 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ContactsManagementMockProvider } from '../composition' +import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios' +import { OrganizationPickerDialog } from '../organization-picker-dialog' + +describe('OrganizationPickerDialog pending state', () => { + it('keeps selection visible and prevents duplicate add while pending', async () => { + const waits: Array<() => void> = [] + const wait = () => + new Promise((resolve) => { + waits.push(resolve) + }) + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }) + const onOpenChange = vi.fn() + render( + + + + + , + ) + + await waitFor(() => expect(waits).toHaveLength(1)) + await act(async () => waits.shift()?.()) + const user = userEvent.setup() + const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' }) + const candidate = await within(dialog).findByRole('checkbox', { name: /Ada Lovelace/ }) + await user.click(candidate) + await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' })) + await waitFor(() => expect(waits).toHaveLength(1)) + + expect(candidate).toBeChecked() + expect( + within(dialog).getByRole('button', { name: 'contacts.organization.adding' }), + ).toHaveAttribute('aria-disabled', 'true') + expect(onOpenChange).not.toHaveBeenCalled() + await act(async () => waits.shift()?.()) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + }) +}) diff --git a/web/features/contacts/management/composition-context.ts b/web/features/contacts/management/composition-context.ts new file mode 100644 index 00000000000..437eb210d3b --- /dev/null +++ b/web/features/contacts/management/composition-context.ts @@ -0,0 +1,28 @@ +'use client' + +import type { ContactsManagementRepository } from './repository' +import type { ContactsFeatureContextValue } from './types' +import { createContext, use } from 'react' + +export const ContactsManagementRepositoryContext = + createContext(null) +export const ContactsFeatureContext = createContext(null) + +export function useContactsManagementRepository() { + const repository = use(ContactsManagementRepositoryContext) + if (!repository) throw new Error('ContactsManagementProvider is required') + return repository +} + +export function useContactsFeatureContext() { + const context = use(ContactsFeatureContext) + if (!context) throw new Error('ContactsManagementProvider is required') + return context +} + +export function useOptionalContactsManagement() { + return { + context: use(ContactsFeatureContext), + repository: use(ContactsManagementRepositoryContext), + } +} diff --git a/web/features/contacts/management/composition.tsx b/web/features/contacts/management/composition.tsx new file mode 100644 index 00000000000..f78ac0ca5a2 --- /dev/null +++ b/web/features/contacts/management/composition.tsx @@ -0,0 +1,78 @@ +'use client' + +import type { ReactNode } from 'react' +import type { ContactsMockScenarioDefinition } from './mock/scenarios' +import type { ContactsManagementRepository } from './repository' +import type { ContactsFeatureContextValue } from './types' +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' +import { Plan } from '@/app/components/billing/type' +import { IS_CLOUD_EDITION } from '@/config' +import { useProviderContext } from '@/context/provider-context' +import { currentWorkspaceIdAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state' +import { ContactsFeatureContext, ContactsManagementRepositoryContext } from './composition-context' +import { createContactsMockRepository } from './mock/repository' +import { createDefaultContactsScenario } from './mock/scenarios' + +export function ContactsManagementProvider({ + children, + context, + repository, +}: { + children: ReactNode + context: ContactsFeatureContextValue + repository: ContactsManagementRepository +}) { + return ( + + + {children} + + + ) +} + +export function ContactsManagementMockProvider({ + children, + scenario, + wait, +}: { + children: ReactNode + scenario: ContactsMockScenarioDefinition + wait?: () => Promise +}) { + const context = useMemo( + () => ({ + deployment: scenario.deployment, + permissions: scenario.permissions, + workspaceId: scenario.workspaceId, + }), + [scenario], + ) + const repository = useMemo( + () => createContactsMockRepository({ scenario, wait }), + [scenario, wait], + ) + + return ( + + {children} + + ) +} + +export function ContactsManagementRuntimeProvider({ children }: { children: ReactNode }) { + const workspaceId = useAtomValue(currentWorkspaceIdAtom) + const canManage = useAtomValue(isCurrentWorkspaceManagerAtom) + const { plan } = useProviderContext() + const deployment = + plan.type === Plan.enterprise ? 'ee' : IS_CLOUD_EDITION ? 'saas' : ('ce' as const) + const scenario = useMemo(() => { + const defaultScenario = createDefaultContactsScenario(deployment, canManage) + return { ...defaultScenario, workspaceId: workspaceId || defaultScenario.workspaceId } + }, [canManage, deployment, workspaceId]) + + return ( + {children} + ) +} diff --git a/web/features/contacts/management/contact-details-panel.tsx b/web/features/contacts/management/contact-details-panel.tsx new file mode 100644 index 00000000000..ab9a7a2e9cb --- /dev/null +++ b/web/features/contacts/management/contact-details-panel.tsx @@ -0,0 +1,174 @@ +'use client' + +import type { ContactView } from './types' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import { useContactDetails } from './hooks' + +function TypeSpecificDetails({ contact }: { contact: ContactView }) { + const { t } = useTranslation('contacts') + + if (contact.kind === 'workspace') { + return ( + <> + $['details.workspaceRole'])} + value={contact.workspaceRoleSummary} + /> + $['details.membership'])} value={contact.membershipStatus} /> + + ) + } + + if (contact.kind === 'platform') { + return ( + <> + $['details.organizationIdentity'])} + value={contact.organizationIdentity} + /> + $['details.sourceWorkspace'])} + value={contact.sourceWorkspaceSummary} + /> + + ) + } + + return ( + <> + $['details.account'])} + value={t(($) => $['details.notDifyAccount'])} + /> + $['details.workspaceScope'])} + value={t(($) => $['details.currentWorkspaceOnly'])} + /> + $['details.delivery'])} value={t(($) => $['details.emailOnly'])} /> + + ) +} + +function DetailRow({ label, value }: { label: string; value: string | null }) { + const { t } = useTranslation('contacts') + return ( +
+
{label}
+
+ {value || t(($) => $['details.missing'])} +
+
+ ) +} + +export function ContactDetailsPanel({ + contactId, + onClose, +}: { + contactId: string + onClose: () => void +}) { + const { t } = useTranslation('contacts') + const contactQuery = useContactDetails(contactId) + const contact = contactQuery.data + + return ( + + ) +} diff --git a/web/features/contacts/management/directory-page.tsx b/web/features/contacts/management/directory-page.tsx new file mode 100644 index 00000000000..1d1702ee513 --- /dev/null +++ b/web/features/contacts/management/directory-page.tsx @@ -0,0 +1,401 @@ +'use client' + +import type { ReactNode } from 'react' +import type { ContactKindFilter, ContactView } from './types' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Input } from '@langgenius/dify-ui/input' +import { + parseAsInteger, + parseAsString, + parseAsStringLiteral, + useQueryState, + useQueryStates, +} from 'nuqs' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContactsFeatureContext } from './composition-context' +import { ContactDetailsPanel } from './contact-details-panel' +import { ExternalContactDialog } from './external-contact-dialog' +import { useContactsDirectory } from './hooks' +import { OrganizationPickerDialog } from './organization-picker-dialog' + +const contactKindFilters = ['all', 'workspace', 'platform', 'external'] as const +const searchParser = parseAsString.withDefault('') +const kindParser = parseAsStringLiteral(contactKindFilters).withDefault('all') +const contactIdParser = parseAsString +const loadedPagesParser = parseAsInteger.withDefault(1) + +function ContactTypeBadge({ kind }: { kind: ContactView['kind'] }) { + const { t } = useTranslation('contacts') + return ( + + {t(($) => $[`type.${kind}`])} + + ) +} + +function ContactRow({ + contact, + onOpen, + registerTrigger, +}: { + contact: ContactView + onOpen: () => void + registerTrigger: (element: HTMLButtonElement | null) => void +}) { + const { t } = useTranslation('contacts') + return ( + + + + + + + + + + $['directory.channel.email'])} + className="i-ri-mail-line size-4" + /> + {contact.channels.imIdentities.map((identity) => ( + + {identity.provider} + + ))} + + + + {new Date(contact.joinedAt).toLocaleDateString()} + + + ) +} + +function DirectoryState({ + action, + description, + icon, + title, +}: { + action?: ReactNode + description: string + icon: string + title: string +}) { + return ( +
+ +

{title}

+

{description}

+ {action &&
{action}
} +
+ ) +} + +export function ContactsDirectoryPage() { + const { t } = useTranslation('contacts') + const context = useContactsFeatureContext() + const [browsing, setBrowsing] = useQueryStates({ + contact_kind: kindParser, + contact_pages: loadedPagesParser, + contact_search: searchParser, + }) + const search = browsing.contact_search + const kind = browsing.contact_kind + const loadedPages = browsing.contact_pages + const [contactId, setContactId] = useQueryState('contact_id', contactIdParser) + const [externalDialogOpen, setExternalDialogOpen] = useState(false) + const [organizationDialogOpen, setOrganizationDialogOpen] = useState(false) + const rowTriggersRef = useRef(new Map()) + const selectedContactIdRef = useRef(null) + const directoryQuery = useContactsDirectory({ kind, pageSize: 20, search }) + const fetchNextPage = directoryQuery.fetchNextPage + const filters = contactKindFilters.filter( + (filter) => filter !== 'platform' || context.deployment === 'ee', + ) + const hasFilters = Boolean(search) || kind !== 'all' + const currentPageCount = directoryQuery.data?.pages.length ?? 0 + + useEffect(() => { + if ( + currentPageCount > 0 && + currentPageCount < loadedPages && + directoryQuery.hasNextPage && + !directoryQuery.isFetchingNextPage && + !directoryQuery.isFetchNextPageError + ) { + void fetchNextPage() + } + }, [ + currentPageCount, + directoryQuery.hasNextPage, + directoryQuery.isFetchNextPageError, + directoryQuery.isFetchingNextPage, + fetchNextPage, + loadedPages, + ]) + + function updateSearch(value: string) { + void setBrowsing({ contact_pages: null, contact_search: value || null }) + } + + function updateKind(value: ContactKindFilter) { + void setBrowsing({ + contact_kind: value === 'all' ? null : value, + contact_pages: null, + }) + } + + function openDetails(id: string) { + selectedContactIdRef.current = id + void setContactId(id) + } + + function closeDetails() { + const trigger = selectedContactIdRef.current + ? rowTriggersRef.current.get(selectedContactIdRef.current) + : null + void setContactId(null).then(() => trigger?.focus()) + } + + function clearFilters() { + void setBrowsing({ contact_kind: null, contact_pages: null, contact_search: null }) + } + + async function loadMore() { + const nextResult = await fetchNextPage() + if (!nextResult.isFetchNextPageError) + void setBrowsing({ contact_pages: nextResult.data?.pages.length ?? loadedPages }) + } + + if (!context.permissions.canViewContacts) { + return ( + $['directory.noAccessDescription'])} + icon="i-ri-lock-2-line" + title={t(($) => $['directory.noAccessTitle'])} + /> + ) + } + + return ( +
+
+
+

{t(($) => $['directory.title'])}

+ + {t(($) => $['directory.learnMore'])} + +
+
+
$['directory.filters'])} + className="flex max-w-full gap-1 overflow-x-auto rounded-lg bg-background-default-subtle p-1" + > + {filters.map((filter) => ( + + ))} +
+
+ + $['directory.search'])} + className="w-full pl-9" + placeholder={t(($) => $['directory.search'])} + value={search} + onChange={(event) => updateSearch(event.target.value)} + /> +
+ {context.permissions.canManageContacts && ( +
+ {context.deployment === 'ee' ? ( + + }> + + {t(($) => $['directory.addContact'])} + + + + setOrganizationDialogOpen(true)}> + {t(($) => $['directory.addFromPlatform'])} + + setExternalDialogOpen(true)}> + {t(($) => $['directory.addExternal'])} + + + + ) : ( + + )} +
+ )} +
+ {!context.permissions.canManageContacts && ( +

+ {t(($) => $['directory.viewOnly'])} +

+ )} +
+
+ {directoryQuery.isPending && ( +
$['directory.loading'])} + className="space-y-2 rounded-xl border border-divider-subtle p-3" + > + {[0, 1, 2, 3, 4].map((key) => ( +
+ ))} +
+ )} + {directoryQuery.isError && !directoryQuery.contacts.length && ( + directoryQuery.refetch()}> + {t(($) => $['action.retry'])} + + } + description={t(($) => $['directory.errorDescription'])} + icon="i-ri-error-warning-line" + title={t(($) => $['directory.errorTitle'])} + /> + )} + {!directoryQuery.isPending && + !directoryQuery.isError && + !directoryQuery.contacts.length && ( + {t(($) => $['action.clearFilters'])} + ) : undefined + } + description={t( + ($) => + $[hasFilters ? 'directory.noResultsDescription' : 'directory.emptyDescription'], + )} + icon={hasFilters ? 'i-ri-search-line' : 'i-ri-contacts-book-2-line'} + title={t(($) => $[hasFilters ? 'directory.noResultsTitle' : 'directory.emptyTitle'])} + /> + )} + {directoryQuery.contacts.length > 0 && ( +
+ + + + + + + + + + + {directoryQuery.contacts.map((contact) => ( + { + if (element) rowTriggersRef.current.set(contact.id, element) + else rowTriggersRef.current.delete(contact.id) + }} + onOpen={() => openDetails(contact.id)} + /> + ))} + +
+ {t(($) => $['directory.column.name'])} + + {t(($) => $['directory.column.type'])} + + {t(($) => $['directory.column.channels'])} + + {t(($) => $['directory.column.joined'])} +
+ {directoryQuery.hasNextPage && ( +
+ {directoryQuery.isFetchNextPageError && ( +

+ {t(($) => $['directory.pageError'])} +

+ )} + +
+ )} +
+ )} +
+ {contactId && } + {}} + /> + {context.deployment === 'ee' && ( + + )} +
+ ) +} diff --git a/web/features/contacts/management/external-contact-dialog.tsx b/web/features/contacts/management/external-contact-dialog.tsx new file mode 100644 index 00000000000..dabc853fe01 --- /dev/null +++ b/web/features/contacts/management/external-contact-dialog.tsx @@ -0,0 +1,233 @@ +'use client' + +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field' +import { Form } from '@langgenius/dify-ui/form' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContactsFeatureContext } from './composition-context' +import { useCreateExternalContact } from './hooks' + +type ExternalContactDraft = { + displayName: string + email: string +} + +type ExternalContactDialogProps = { + onCreated: (contactId: string) => void + onOpenChange: (open: boolean) => void + open: boolean +} + +const emptyDraft: ExternalContactDraft = { displayName: '', email: '' } +function isValidEmail(value: string) { + const parts = value.split('@') + if (parts.length !== 2) return false + const [local, domain] = parts + return Boolean( + local && + domain && + !value.includes(' ') && + domain.includes('.') && + !domain.startsWith('.') && + !domain.endsWith('.'), + ) +} + +export function ExternalContactDialog({ + onCreated, + onOpenChange, + open, +}: ExternalContactDialogProps) { + const { t } = useTranslation('contacts') + const context = useContactsFeatureContext() + const createExternalContact = useCreateExternalContact() + const [draft, setDraft] = useState(emptyDraft) + const [fieldError, setFieldError] = useState< + 'name_required' | 'email_required' | 'email_invalid' | null + >(null) + const [resultError, setResultError] = useState(null) + const resetMutation = createExternalContact.reset + + function resetDialog() { + setDraft(emptyDraft) + setFieldError(null) + setResultError(null) + resetMutation() + } + + function closeDialog() { + if (createExternalContact.isPending) return + onOpenChange(false) + resetDialog() + } + + function updateDraft(field: keyof ExternalContactDraft, value: string) { + setDraft((current) => ({ ...current, [field]: value })) + setFieldError(null) + setResultError(null) + } + + async function handleSubmit() { + if (!draft.displayName.trim()) { + setFieldError('name_required') + return + } + if (!draft.email.trim()) { + setFieldError('email_required') + return + } + if (!isValidEmail(draft.email.trim())) { + setFieldError('email_invalid') + return + } + + const result = await createExternalContact.mutateAsync({ + displayName: draft.displayName.trim(), + email: draft.email.trim(), + workspaceId: context.workspaceId, + }) + if (result.kind === 'created') { + onOpenChange(false) + onCreated(result.contactId) + resetDialog() + return + } + + setResultError(result.kind) + } + + const fieldErrorMessage = fieldError ? t(($) => $[`external.validation.${fieldError}`]) : null + const resultErrorMessage = (() => { + switch (resultError) { + case 'duplicate_external_contact': + return t(($) => $['external.result.duplicate_external_contact']) + case 'matches_workspace_contact': + return t(($) => $['external.result.matches_workspace_contact']) + case 'matches_platform_contact': + return t(($) => $['external.result.matches_platform_contact']) + case 'failed': + return t(($) => $['external.result.failed']) + default: + return null + } + })() + + return ( + !nextOpen && closeDialog()} + disablePointerDismissal={createExternalContact.isPending} + > + + $['action.close'])} + disabled={createExternalContact.isPending} + /> +
+ + {t(($) => $['external.title'])} + + + {t(($) => $['external.description'])} + +
+ onFormSubmit={handleSubmit} className="px-6 pb-6"> +
+ $['external.avatarFallback'])} + size="3xl" + /> +
+
+ + {t(($) => $['external.name'])} + updateDraft('displayName', value)} + /> + {fieldError === 'name_required' && ( + + )} + + {t(($) => $['external.validation.name_required'])} + + + + {t(($) => $['external.email'])} + updateDraft('email', value)} + /> + {fieldError?.startsWith('email') && ( + + )} + + {t(($) => $['external.validation.email_required'])} + + + {t(($) => $['external.validation.email_invalid'])} + + +
+ {resultErrorMessage && ( +
+ {resultErrorMessage} +
+ )} +
+ + +
+ +
+
+ ) +} diff --git a/web/features/contacts/management/feature-flag.ts b/web/features/contacts/management/feature-flag.ts new file mode 100644 index 00000000000..04bd4ac1461 --- /dev/null +++ b/web/features/contacts/management/feature-flag.ts @@ -0,0 +1,3 @@ +import { ENABLE_FEATURE_PREVIEW } from '@/config' + +export const isContactsManagementEnabled = () => ENABLE_FEATURE_PREVIEW diff --git a/web/features/contacts/management/hooks.ts b/web/features/contacts/management/hooks.ts new file mode 100644 index 00000000000..0b2405bda9a --- /dev/null +++ b/web/features/contacts/management/hooks.ts @@ -0,0 +1,165 @@ +'use client' + +import type { + ContactsListQuery, + CreateExternalContactCommand, + OrganizationCandidateQuery, + RemoveMemberCommand, +} from './types' +import { + infiniteQueryOptions, + mutationOptions, + queryOptions, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useContactsFeatureContext, useContactsManagementRepository } from './composition-context' +import { mergeContactPages } from './mock/repository' +import { contactsManagementQueryKeys } from './query-keys' + +export function useContactsDirectory(query: Omit) { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + const result = useInfiniteQuery( + infiniteQueryOptions({ + initialPageParam: null as string | null, + queryFn: ({ pageParam }: { pageParam: string | null }) => + repository.listContacts({ ...query, cursor: pageParam, deployment: context.deployment }), + queryKey: [...contactsManagementQueryKeys.directory(context, query), repository] as const, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + ) + + return { + contacts: mergeContactPages(result.data?.pages.map((page) => page.items) ?? []), + data: result.data, + error: result.error, + fetchNextPage: result.fetchNextPage, + hasNextPage: result.hasNextPage, + isError: result.isError, + isFetchNextPageError: result.isFetchNextPageError, + isFetchingNextPage: result.isFetchingNextPage, + isPending: result.isPending, + refetch: result.refetch, + } +} + +export function useContactDetails(contactId: string | null) { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + return useQuery( + queryOptions({ + enabled: Boolean(contactId), + queryFn: () => repository.getContact(contactId as string), + queryKey: [ + ...contactsManagementQueryKeys.detail(context.workspaceId, contactId ?? 'none'), + repository, + ] as const, + }), + ) +} + +export function useOrganizationCandidates( + query: Omit, + enabled: boolean, +) { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + const result = useInfiniteQuery( + infiniteQueryOptions({ + enabled: enabled && context.deployment === 'ee', + initialPageParam: null as string | null, + queryFn: ({ pageParam }: { pageParam: string | null }) => + repository.searchOrganizationCandidates({ ...query, cursor: pageParam }), + queryKey: [ + ...contactsManagementQueryKeys.organizationCandidates(context.workspaceId, query), + repository, + ] as const, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + ) + + return { + candidates: result.data?.pages.flatMap((page) => page.items) ?? [], + error: result.error, + fetchNextPage: result.fetchNextPage, + hasNextPage: result.hasNextPage, + isError: result.isError, + isFetchingNextPage: result.isFetchingNextPage, + isPending: result.isPending, + refetch: result.refetch, + } +} + +export function useCreateExternalContact() { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + const queryClient = useQueryClient() + + return useMutation( + mutationOptions({ + mutationFn: (command: CreateExternalContactCommand) => + repository.createExternalContact(command), + onSuccess: (result) => { + if (result.kind !== 'created') return + void queryClient.invalidateQueries({ + queryKey: contactsManagementQueryKeys.all(context.workspaceId), + }) + }, + }), + ) +} + +export function useAddPlatformContacts() { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + const queryClient = useQueryClient() + + return useMutation( + mutationOptions({ + mutationFn: (candidateIds: string[]) => repository.addPlatformContacts({ candidateIds }), + onSuccess: (result) => { + if (result.kind !== 'added') return + void queryClient.invalidateQueries({ + queryKey: contactsManagementQueryKeys.all(context.workspaceId), + }) + }, + }), + ) +} + +export function useMemberRemovalImpact(memberId: string, enabled: boolean) { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + return useQuery( + queryOptions({ + enabled, + queryFn: () => repository.getMemberRemovalImpact(memberId), + queryKey: [ + ...contactsManagementQueryKeys.all(context.workspaceId), + 'member-removal-impact', + memberId, + repository, + ] as const, + }), + ) +} + +export function useRemoveContactMember() { + const context = useContactsFeatureContext() + const repository = useContactsManagementRepository() + const queryClient = useQueryClient() + return useMutation( + mutationOptions({ + mutationFn: (command: RemoveMemberCommand) => repository.removeMember(command), + onSuccess: (result) => { + if (result.kind !== 'removed') return + void queryClient.invalidateQueries({ + queryKey: contactsManagementQueryKeys.all(context.workspaceId), + }) + }, + }), + ) +} diff --git a/web/features/contacts/management/member-removal-dialog.tsx b/web/features/contacts/management/member-removal-dialog.tsx new file mode 100644 index 00000000000..a83e894962e --- /dev/null +++ b/web/features/contacts/management/member-removal-dialog.tsx @@ -0,0 +1,144 @@ +'use client' + +import type { Member } from '@/models/common' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContactsFeatureContext } from './composition-context' +import { useRemoveContactMember } from './hooks' + +export function MemberRemovalContactImpactDialog({ + member, + onOpenChange, + onRemoved, + open, +}: { + member: Member + onOpenChange: (open: boolean) => void + onRemoved: () => void + open: boolean +}) { + const { t } = useTranslation('contacts') + const context = useContactsFeatureContext() + const removal = useRemoveContactMember() + const [keepAsPlatformContact, setKeepAsPlatformContact] = useState(true) + const [failed, setFailed] = useState(false) + const resetMutation = removal.reset + const isEnterprise = context.deployment === 'ee' + + function resetDialog() { + setKeepAsPlatformContact(true) + setFailed(false) + resetMutation() + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen && removal.isPending) return + onOpenChange(nextOpen) + if (!nextOpen) resetDialog() + } + + async function handleRemove() { + if (removal.isPending) return + setFailed(false) + const result = await removal.mutateAsync({ + keepAsPlatformContact: isEnterprise && keepAsPlatformContact, + memberId: member.id, + }) + if (result.kind === 'failed') { + setFailed(true) + return + } + onOpenChange(false) + onRemoved() + resetDialog() + } + + return ( + + +
+
+ + {t(($) => $['memberRemoval.title'], { memberName: member.name || member.email })} + + + {t( + ($) => + $[ + isEnterprise + ? 'memberRemoval.eeDescription' + : 'memberRemoval.standardDescription' + ], + )} + +
+
+ + + + {member.name || member.email} + + + {member.email} + + +
+ {isEnterprise && ( +
+ + + + {t(($) => $['memberRemoval.keepPlatform'])} + + + {t(($) => $['memberRemoval.keepPlatformDescription'])} + + +
+ )} + {failed && ( +

+ {t(($) => $['memberRemoval.failed'])} +

+ )} +
+ + + {t(($) => $['action.cancel'])} + + + {removal.isPending + ? t(($) => $['memberRemoval.removing']) + : t(($) => $['memberRemoval.remove'])} + + +
+
+ ) +} diff --git a/web/features/contacts/management/mock/repository.ts b/web/features/contacts/management/mock/repository.ts new file mode 100644 index 00000000000..7e8fc14ae35 --- /dev/null +++ b/web/features/contacts/management/mock/repository.ts @@ -0,0 +1,205 @@ +import type { ContactsManagementRepository } from '../repository' +import type { ContactPage, ContactView, PlatformContactView } from '../types' +import type { ContactsMockScenarioDefinition } from './scenarios' + +type CreateContactsMockRepositoryOptions = { + scenario: ContactsMockScenarioDefinition + wait?: () => Promise +} + +const normalizeEmail = (email: string) => email.trim().toLocaleLowerCase() + +const matchesSearch = (search: string, ...values: string[]) => { + const normalizedSearch = search.trim().toLocaleLowerCase() + return ( + !normalizedSearch || + values.some((value) => value.toLocaleLowerCase().includes(normalizedSearch)) + ) +} + +function paginate(items: T[], cursor: string | null, pageSize: number): ContactPage { + const offset = cursor ? Number.parseInt(cursor, 10) : 0 + const safeOffset = Number.isFinite(offset) ? offset : 0 + const pageItems = items.slice(safeOffset, safeOffset + pageSize) + const nextOffset = safeOffset + pageItems.length + + return { + items: structuredClone(pageItems), + nextCursor: nextOffset < items.length ? String(nextOffset) : null, + } +} + +export function mergeContactPages(pages: ContactView[][]): ContactView[] { + const contacts = new Map() + + for (const page of pages) { + for (const contact of page) contacts.set(contact.id, contact) + } + + return [...contacts.values()] +} + +export function createContactsMockRepository({ + scenario, + wait = async () => {}, +}: CreateContactsMockRepositoryOptions): ContactsManagementRepository { + let contacts = structuredClone(scenario.contacts) + const organizationCandidates = structuredClone(scenario.organizationCandidates) + let createdExternalCount = 0 + + const getExistingContactIds = () => new Set(contacts.map((contact) => contact.id)) + const getExistingEmails = () => new Set(contacts.map((contact) => normalizeEmail(contact.email))) + + return { + async listContacts(query) { + await wait() + if (scenario.failures.directory) throw new Error('contacts_directory_failed') + if (query.cursor && scenario.failures.nextPage) throw new Error('contacts_next_page_failed') + + const filtered = contacts.filter((contact) => { + const matchesKind = query.kind === 'all' || contact.kind === query.kind + return matchesKind && matchesSearch(query.search, contact.displayName, contact.email) + }) + + return paginate(filtered, query.cursor, query.pageSize) + }, + + async getContact(contactId) { + await wait() + if (scenario.failures.detail) throw new Error('contact_detail_failed') + return structuredClone(contacts.find((contact) => contact.id === contactId) ?? null) + }, + + async createExternalContact(command) { + await wait() + if (scenario.failures.createExternal) return { kind: 'failed' } + + const email = normalizeEmail(command.email) + const contact = contacts.find((item) => normalizeEmail(item.email) === email) + if (contact?.kind === 'external') { + return { contactId: contact.id, kind: 'duplicate_external_contact' } + } + if (contact?.kind === 'workspace') { + return { contactId: contact.id, kind: 'matches_workspace_contact' } + } + if (contact?.kind === 'platform') { + return { contactId: contact.id, kind: 'matches_platform_contact' } + } + + const organizationIdentity = organizationCandidates.find( + (candidate) => normalizeEmail(candidate.email) === email, + ) + if (organizationIdentity) { + return { contactId: organizationIdentity.id, kind: 'matches_platform_contact' } + } + + createdExternalCount += 1 + const contactId = `contact-external-created-${createdExternalCount}` + contacts = [ + ...contacts, + { + avatarUrl: null, + channels: { email, imIdentities: [] }, + displayName: command.displayName.trim(), + email, + emailOnly: true, + id: contactId, + joinedAt: '2026-07-17T00:00:00.000Z', + kind: 'external', + workspaceId: command.workspaceId, + }, + ] + return { contactId, kind: 'created' } + }, + + async searchOrganizationCandidates(query) { + await wait() + if (scenario.failures.organization) throw new Error('organization_candidates_failed') + + const existingContactIds = getExistingContactIds() + const existingEmails = getExistingEmails() + const filtered = organizationCandidates.filter((candidate) => { + const isExisting = + existingContactIds.has(candidate.id) || + existingEmails.has(normalizeEmail(candidate.email)) + return !isExisting && matchesSearch(query.search, candidate.displayName, candidate.email) + }) + return paginate(filtered, query.cursor, query.pageSize) + }, + + async addPlatformContacts(command) { + await wait() + if (scenario.failures.addPlatform) return { kind: 'failed' } + + const selectedCandidates = organizationCandidates.filter((candidate) => + command.candidateIds.includes(candidate.id), + ) + const existingEmails = getExistingEmails() + const newContacts: PlatformContactView[] = selectedCandidates + .filter((candidate) => !existingEmails.has(normalizeEmail(candidate.email))) + .map((candidate) => ({ + avatarUrl: candidate.avatarUrl, + channels: { email: candidate.email, imIdentities: [] }, + displayName: candidate.displayName, + email: candidate.email, + id: `contact-platform-${candidate.id}`, + joinedAt: '2026-07-17T00:00:00.000Z', + kind: 'platform', + organizationIdentity: candidate.organizationIdentity, + sourceWorkspaceSummary: candidate.sourceWorkspaceSummary, + })) + + contacts = [...contacts, ...newContacts] + return { contactIds: newContacts.map((contact) => contact.id), kind: 'added' } + }, + + async getMemberRemovalImpact(memberId) { + await wait() + const contact = contacts.find( + (item) => item.kind === 'workspace' && item.memberId === memberId, + ) + return { + contactId: contact?.id ?? null, + deployment: scenario.deployment, + memberId, + } + }, + + async removeMember(command) { + await wait() + if (scenario.failures.removal) return { kind: 'failed' } + + const contact = contacts.find( + (item) => item.kind === 'workspace' && item.memberId === command.memberId, + ) + if (!contact) { + return { contactId: null, contactOutcome: 'not_found', kind: 'removed' } + } + + if (scenario.deployment === 'ee' && command.keepAsPlatformContact) { + contacts = contacts.map((item) => { + if (item.id !== contact.id) return item + return { + avatarUrl: item.avatarUrl, + channels: item.channels, + displayName: item.displayName, + email: item.email, + id: item.id, + joinedAt: item.joinedAt, + kind: 'platform', + organizationIdentity: `retained-${command.memberId}`, + sourceWorkspaceSummary: 'Former workspace member', + } + }) + return { + contactId: contact.id, + contactOutcome: 'converted_to_platform', + kind: 'removed', + } + } + + contacts = contacts.filter((item) => item.id !== contact.id) + return { contactId: contact.id, contactOutcome: 'removed', kind: 'removed' } + }, + } +} diff --git a/web/features/contacts/management/mock/scenarios.ts b/web/features/contacts/management/mock/scenarios.ts new file mode 100644 index 00000000000..325ae399a1f --- /dev/null +++ b/web/features/contacts/management/mock/scenarios.ts @@ -0,0 +1,226 @@ +import type { + ContactsDeployment, + ContactsPermissions, + ContactView, + ExternalContactView, + OrganizationCandidate, + PlatformContactView, + WorkspaceContactView, +} from '../types' + +export const ContactsMockScenario = { + AddPlatformFailure: 'add-platform-failure', + CeMixed: 'ce-mixed', + DetailFailure: 'detail-failure', + DirectoryFailure: 'directory-failure', + EeMixed: 'ee-mixed', + Empty: 'empty', + ExternalFailure: 'external-failure', + NextPageFailure: 'next-page-failure', + NoAccess: 'no-access', + OrganizationFailure: 'organization-failure', + Paginated: 'paginated', + ReadOnly: 'read-only', + RemovalFailure: 'removal-failure', + SaasMixed: 'saas-mixed', +} as const + +export type ContactsMockScenario = (typeof ContactsMockScenario)[keyof typeof ContactsMockScenario] + +export type ContactsMockFailurePlan = { + addPlatform?: boolean + createExternal?: boolean + detail?: boolean + directory?: boolean + nextPage?: boolean + organization?: boolean + removal?: boolean +} + +export type ContactsMockScenarioDefinition = { + contacts: ContactView[] + deployment: ContactsDeployment + failures: ContactsMockFailurePlan + organizationCandidates: OrganizationCandidate[] + permissions: ContactsPermissions + workspaceId: string +} + +const workspaceContact: WorkspaceContactView = { + avatarUrl: null, + channels: { + email: 'owner@example.com', + imIdentities: [{ identity: 'owner', provider: 'Slack' }], + }, + displayName: 'Ralph Edwards', + email: 'owner@example.com', + id: 'contact-owner', + joinedAt: '2026-01-12T08:00:00.000Z', + kind: 'workspace', + memberId: 'member-owner', + membershipStatus: 'active', + workspaceRoleSummary: 'Admin', +} + +const platformContact: PlatformContactView = { + avatarUrl: null, + channels: { + email: 'platform@example.com', + imIdentities: [{ identity: 'platform-contact', provider: 'Feishu' }], + }, + displayName: 'Leslie Alexander', + email: 'platform@example.com', + id: 'contact-platform', + joinedAt: '2026-02-04T08:00:00.000Z', + kind: 'platform', + organizationIdentity: 'org-user-platform', + sourceWorkspaceSummary: 'Mobile Dev', +} + +const externalContact: ExternalContactView = { + avatarUrl: null, + channels: { email: 'external@example.com', imIdentities: [] }, + displayName: 'Courtney Henry', + email: 'external@example.com', + emailOnly: true, + id: 'contact-external', + joinedAt: '2026-03-20T08:00:00.000Z', + kind: 'external', + workspaceId: 'workspace-1', +} + +const organizationCandidates: OrganizationCandidate[] = [ + { + avatarUrl: null, + displayName: 'Ada Lovelace', + email: 'ada@example.com', + id: 'org-candidate-ada', + organizationIdentity: 'org-user-ada', + sourceWorkspaceSummary: 'Dev Team', + }, + { + avatarUrl: null, + displayName: 'Grace Hopper', + email: 'grace@example.com', + id: 'org-candidate-grace', + organizationIdentity: 'org-user-grace', + sourceWorkspaceSummary: 'Platform Team', + }, + { + avatarUrl: null, + displayName: 'Ralph Edwards', + email: 'owner@example.com', + id: 'org-candidate-owner', + organizationIdentity: 'org-user-owner', + sourceWorkspaceSummary: 'Current workspace', + }, +] + +const managerPermissions: ContactsPermissions = { + canManageContacts: true, + canManageMembers: true, + canViewContacts: true, +} + +const clone = (value: T): T => structuredClone(value) + +function mixedContacts(deployment: ContactsDeployment): ContactView[] { + if (deployment === 'ee') return clone([workspaceContact, platformContact, externalContact]) + + return clone([workspaceContact, externalContact]) +} + +function paginatedContacts(): ContactView[] { + const contacts = mixedContacts('ee') + for (let index = 1; index <= 20; index += 1) { + contacts.push({ + avatarUrl: null, + channels: { email: `partner-${index}@example.com`, imIdentities: [] }, + displayName: `Partner ${index}`, + email: `partner-${index}@example.com`, + emailOnly: true, + id: `contact-partner-${index}`, + joinedAt: '2026-04-01T08:00:00.000Z', + kind: 'external', + workspaceId: 'workspace-1', + }) + } + return contacts +} + +export function createContactsMockScenario( + scenario: ContactsMockScenario, +): ContactsMockScenarioDefinition { + const base: ContactsMockScenarioDefinition = { + contacts: mixedContacts('ee'), + deployment: 'ee', + failures: {}, + organizationCandidates: clone(organizationCandidates), + permissions: clone(managerPermissions), + workspaceId: 'workspace-1', + } + + switch (scenario) { + case ContactsMockScenario.CeMixed: + return { ...base, contacts: mixedContacts('ce'), deployment: 'ce' } + case ContactsMockScenario.SaasMixed: + return { ...base, contacts: mixedContacts('saas'), deployment: 'saas' } + case ContactsMockScenario.Empty: + return { ...base, contacts: [] } + case ContactsMockScenario.DirectoryFailure: + return { ...base, failures: { directory: true } } + case ContactsMockScenario.DetailFailure: + return { ...base, failures: { detail: true } } + case ContactsMockScenario.NextPageFailure: + return { ...base, contacts: paginatedContacts(), failures: { nextPage: true } } + case ContactsMockScenario.Paginated: + return { ...base, contacts: paginatedContacts() } + case ContactsMockScenario.ExternalFailure: + return { ...base, failures: { createExternal: true } } + case ContactsMockScenario.OrganizationFailure: + return { ...base, failures: { organization: true } } + case ContactsMockScenario.AddPlatformFailure: + return { ...base, failures: { addPlatform: true } } + case ContactsMockScenario.RemovalFailure: + return { ...base, failures: { removal: true } } + case ContactsMockScenario.ReadOnly: + return { + ...base, + permissions: { + canManageContacts: false, + canManageMembers: false, + canViewContacts: true, + }, + } + case ContactsMockScenario.NoAccess: + return { + ...base, + permissions: { + canManageContacts: false, + canManageMembers: false, + canViewContacts: false, + }, + } + case ContactsMockScenario.EeMixed: + return base + } +} + +export function createDefaultContactsScenario(deployment: ContactsDeployment, canManage: boolean) { + const scenario = createContactsMockScenario( + deployment === 'ee' + ? ContactsMockScenario.EeMixed + : deployment === 'saas' + ? ContactsMockScenario.SaasMixed + : ContactsMockScenario.CeMixed, + ) + + return { + ...scenario, + permissions: { + canManageContacts: canManage, + canManageMembers: canManage, + canViewContacts: canManage, + }, + } +} diff --git a/web/features/contacts/management/organization-picker-dialog.tsx b/web/features/contacts/management/organization-picker-dialog.tsx new file mode 100644 index 00000000000..828313b3c87 --- /dev/null +++ b/web/features/contacts/management/organization-picker-dialog.tsx @@ -0,0 +1,195 @@ +'use client' + +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { Checkbox } from '@langgenius/dify-ui/checkbox' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useAddPlatformContacts, useOrganizationCandidates } from './hooks' + +export function OrganizationPickerDialog({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void + open: boolean +}) { + const { t } = useTranslation('contacts') + const [search, setSearch] = useState('') + const [selectedIds, setSelectedIds] = useState([]) + const [mutationError, setMutationError] = useState(false) + const candidatesQuery = useOrganizationCandidates({ pageSize: 20, search }, open) + const addPlatformContacts = useAddPlatformContacts() + const resetMutation = addPlatformContacts.reset + + function resetDialog() { + setSearch('') + setSelectedIds([]) + setMutationError(false) + resetMutation() + } + + function closeDialog() { + if (addPlatformContacts.isPending) return + onOpenChange(false) + resetDialog() + } + + function toggleCandidate(candidateId: string, checked: boolean) { + setMutationError(false) + setSelectedIds((current) => + checked + ? [...new Set([...current, candidateId])] + : current.filter((id) => id !== candidateId), + ) + } + + async function handleAdd() { + if (!selectedIds.length || addPlatformContacts.isPending) return + const result = await addPlatformContacts.mutateAsync(selectedIds) + if (result.kind === 'added') { + onOpenChange(false) + resetDialog() + return + } + setMutationError(true) + } + + return ( + !nextOpen && closeDialog()} + disablePointerDismissal={addPlatformContacts.isPending} + > + + $['action.close'])} + disabled={addPlatformContacts.isPending} + /> +
+ + {t(($) => $['organization.title'])} + + + {t(($) => $['organization.description'])} + +
+ + $['organization.search'])} + className="pl-9" + disabled={addPlatformContacts.isPending} + placeholder={t(($) => $['organization.search'])} + value={search} + onChange={(event) => setSearch(event.target.value)} + /> +
+
+
+ {candidatesQuery.isPending && ( +
$['organization.loading'])} + > + {[0, 1, 2].map((key) => ( +
+ ))} +
+ )} + {candidatesQuery.isError && ( +
+

+ {t(($) => $['organization.error'])} +

+ +
+ )} + {!candidatesQuery.isPending && + !candidatesQuery.isError && + !candidatesQuery.candidates.length && ( +
+ {t(($) => $['organization.empty'])} +
+ )} + {candidatesQuery.candidates.map((candidate) => { + const selected = selectedIds.includes(candidate.id) + return ( + + ) + })} +
+
+ {mutationError && ( +

+ {t(($) => $['organization.addFailed'])} +

+ )} +
+ + {t(($) => $['organization.selected'], { count: selectedIds.length })} + +
+ + +
+
+
+ +
+ ) +} diff --git a/web/features/contacts/management/query-keys.ts b/web/features/contacts/management/query-keys.ts new file mode 100644 index 00000000000..f8035c35780 --- /dev/null +++ b/web/features/contacts/management/query-keys.ts @@ -0,0 +1,25 @@ +import type { + ContactsFeatureContextValue, + ContactsListQuery, + OrganizationCandidateQuery, +} from './types' + +export const contactsManagementQueryKeys = { + all: (workspaceId: string) => ['contacts-management', workspaceId] as const, + detail: (workspaceId: string, contactId: string) => + [...contactsManagementQueryKeys.all(workspaceId), 'detail', contactId] as const, + directory: ( + context: Pick, + query: Omit, + ) => + [ + ...contactsManagementQueryKeys.all(context.workspaceId), + 'directory', + context.deployment, + query, + ] as const, + organizationCandidates: ( + workspaceId: string, + query: Omit, + ) => [...contactsManagementQueryKeys.all(workspaceId), 'organization-candidates', query] as const, +} diff --git a/web/features/contacts/management/repository.ts b/web/features/contacts/management/repository.ts new file mode 100644 index 00000000000..ec699410493 --- /dev/null +++ b/web/features/contacts/management/repository.ts @@ -0,0 +1,28 @@ +import type { + AddPlatformContactsCommand, + AddPlatformContactsResult, + ContactPage, + ContactsListQuery, + ContactView, + CreateExternalContactCommand, + CreateExternalContactResult, + MemberRemovalImpact, + OrganizationCandidate, + OrganizationCandidateQuery, + RemoveMemberCommand, + RemoveMemberResult, +} from './types' + +export type ContactsManagementRepository = { + addPlatformContacts: (command: AddPlatformContactsCommand) => Promise + createExternalContact: ( + command: CreateExternalContactCommand, + ) => Promise + getContact: (contactId: string) => Promise + getMemberRemovalImpact: (memberId: string) => Promise + listContacts: (query: ContactsListQuery) => Promise> + removeMember: (command: RemoveMemberCommand) => Promise + searchOrganizationCandidates: ( + query: OrganizationCandidateQuery, + ) => Promise> +} diff --git a/web/features/contacts/management/types.ts b/web/features/contacts/management/types.ts new file mode 100644 index 00000000000..6ab9720dff9 --- /dev/null +++ b/web/features/contacts/management/types.ts @@ -0,0 +1,120 @@ +export type ContactsDeployment = 'ce' | 'saas' | 'ee' + +export type ContactsPermissions = { + canManageContacts: boolean + canManageMembers: boolean + canViewContacts: boolean +} + +export type ContactsFeatureContextValue = { + deployment: ContactsDeployment + permissions: ContactsPermissions + workspaceId: string +} + +export type ContactKind = 'workspace' | 'platform' | 'external' +export type ContactKindFilter = 'all' | ContactKind + +export type ContactChannelSummary = { + email: string + imIdentities: Array<{ + identity: string + provider: string + }> +} + +type ContactBase = { + avatarUrl: string | null + channels: ContactChannelSummary + displayName: string + email: string + id: string + joinedAt: string +} + +export type WorkspaceContactView = ContactBase & { + kind: 'workspace' + memberId: string + membershipStatus: 'active' | 'pending' + workspaceRoleSummary: string +} + +export type PlatformContactView = ContactBase & { + kind: 'platform' + organizationIdentity: string + sourceWorkspaceSummary: string | null +} + +export type ExternalContactView = ContactBase & { + emailOnly: true + kind: 'external' + workspaceId: string +} + +export type ContactView = WorkspaceContactView | PlatformContactView | ExternalContactView + +export type ContactsListQuery = { + cursor: string | null + deployment: ContactsDeployment + kind: ContactKindFilter + pageSize: number + search: string +} + +export type ContactPage = { + items: T[] + nextCursor: string | null +} + +export type OrganizationCandidate = { + avatarUrl: string | null + displayName: string + email: string + id: string + organizationIdentity: string + sourceWorkspaceSummary: string +} + +export type OrganizationCandidateQuery = { + cursor: string | null + pageSize: number + search: string +} + +export type CreateExternalContactCommand = { + displayName: string + email: string + workspaceId: string +} + +export type CreateExternalContactResult = + | { contactId: string; kind: 'created' } + | { contactId: string; kind: 'duplicate_external_contact' } + | { contactId: string; kind: 'matches_workspace_contact' } + | { contactId: string; kind: 'matches_platform_contact' } + | { kind: 'failed' } + +export type AddPlatformContactsCommand = { + candidateIds: string[] +} + +export type AddPlatformContactsResult = { contactIds: string[]; kind: 'added' } | { kind: 'failed' } + +export type MemberRemovalImpact = { + contactId: string | null + deployment: ContactsDeployment + memberId: string +} + +export type RemoveMemberCommand = { + keepAsPlatformContact: boolean + memberId: string +} + +export type RemoveMemberResult = + | { + contactId: string | null + contactOutcome: 'removed' | 'converted_to_platform' | 'not_found' + kind: 'removed' + } + | { kind: 'failed' } diff --git a/web/i18n/en-US/contacts.json b/web/i18n/en-US/contacts.json index 015215b84b9..58bdc57d396 100644 --- a/web/i18n/en-US/contacts.json +++ b/web/i18n/en-US/contacts.json @@ -1,4 +1,69 @@ { + "action.cancel": "Cancel", + "action.clearFilters": "Clear filters", + "action.close": "Close", + "action.loadMore": "Load more", + "action.retry": "Retry", + "details.account": "Account", + "details.channels": "Channels", + "details.currentWorkspaceOnly": "Current workspace only", + "details.delivery": "Delivery", + "details.emailOnly": "Email only", + "details.error": "Contact details couldn't be loaded.", + "details.joined": "Joined", + "details.loading": "Loading contact details", + "details.membership": "Membership", + "details.missing": "—", + "details.notDifyAccount": "Not a Dify Account", + "details.notFound": "This contact is no longer in the workspace directory.", + "details.organizationIdentity": "Organization identity", + "details.profile": "Contact details", + "details.sourceWorkspace": "Source workspace", + "details.title": "Contact details", + "details.workspaceRole": "Workspace role", + "details.workspaceScope": "Workspace", + "directory.addContact": "Add contact", + "directory.addExternal": "Add external contact", + "directory.addFromPlatform": "Add from Platform", + "directory.channel.email": "Email", + "directory.column.channels": "Channels", + "directory.column.joined": "Joined", + "directory.column.name": "Name", + "directory.column.type": "Type", + "directory.emptyDescription": "Add an external contact, or invite a member to make them available for contact selection.", + "directory.emptyTitle": "No contacts yet", + "directory.errorDescription": "Try again to read the current workspace directory.", + "directory.errorTitle": "Contacts couldn't be loaded", + "directory.filters": "Contact type filters", + "directory.learnMore": "Learn more about Contacts", + "directory.loading": "Loading contacts", + "directory.noAccessDescription": "Only workspace owners and admins can browse the full Contacts directory.", + "directory.noAccessTitle": "You don't have access to Contacts", + "directory.noResultsDescription": "Try another search or clear the current type filter.", + "directory.noResultsTitle": "No matching contacts", + "directory.openDetails": "Open contact details for {{name}}", + "directory.pageError": "More contacts couldn't be loaded. Retry this page.", + "directory.search": "Search name or email", + "directory.title": "Contacts", + "directory.viewOnly": "You can browse Contacts, but only workspace owners and admins can add contacts.", + "external.add": "Add", + "external.adding": "Adding", + "external.avatarFallback": "External contact", + "external.description": "Create a workspace-only contact that can be reached by email.", + "external.email": "Email", + "external.name": "Name", + "external.result.duplicate_external_contact": "An external contact with this email already exists.", + "external.result.failed": "The contact couldn't be added. Keep these details and try again.", + "external.result.matches_platform_contact": "This email belongs to an Organization identity. Add it as a Platform contact instead.", + "external.result.matches_workspace_contact": "This person is already a workspace contact.", + "external.title": "Add External Contact", + "external.validation.email_invalid": "Enter a valid email address.", + "external.validation.email_required": "Email is required.", + "external.validation.name_required": "Name is required.", + "filter.all": "All", + "filter.external": "External", + "filter.platform": "Platform", + "filter.workspace": "Workspace", "imPlatform.action.authorize": "Authorize", "imPlatform.action.authorizing": "Authorizing", "imPlatform.action.configure": "Configure", @@ -112,5 +177,27 @@ "imPlatform.sync.status.success": "Success", "imPlatform.sync.title": "Directory sync", "imPlatform.sync.unsupported": "This provider doesn't support directory sync.", - "imPlatform.title": "IM Platforms" + "imPlatform.title": "IM Platforms", + "memberRemoval.eeDescription": "Removing this member ends their workspace access. Choose whether to keep the same contact as a Platform contact.", + "memberRemoval.failed": "The member couldn't be removed. Nothing has changed; try again.", + "memberRemoval.keepPlatform": "Keep as Platform contact", + "memberRemoval.keepPlatformDescription": "Keep this identity available in Contacts after workspace access is removed.", + "memberRemoval.remove": "Remove", + "memberRemoval.removing": "Removing", + "memberRemoval.standardDescription": "Removing this member also removes their workspace contact from the current Contacts directory.", + "memberRemoval.title": "Remove {{memberName}} from the workspace?", + "organization.add": "Add contacts", + "organization.addFailed": "The selected contacts couldn't be added. Your selection has been kept.", + "organization.adding": "Adding", + "organization.description": "Choose Organization members who are not already in this workspace Contacts directory.", + "organization.empty": "No available Organization members match this search.", + "organization.error": "Organization members couldn't be loaded.", + "organization.loading": "Loading Organization members", + "organization.search": "Search Organization members", + "organization.selectCandidate": "Select {{name}}", + "organization.selected": "{{count}} selected", + "organization.title": "Add from Platform", + "type.external": "External", + "type.platform": "Platform", + "type.workspace": "Workspace" } diff --git a/web/i18n/zh-Hans/contacts.json b/web/i18n/zh-Hans/contacts.json index 8d2cea2e9d3..3905b2f452c 100644 --- a/web/i18n/zh-Hans/contacts.json +++ b/web/i18n/zh-Hans/contacts.json @@ -1,4 +1,69 @@ { + "action.cancel": "取消", + "action.clearFilters": "清除筛选", + "action.close": "关闭", + "action.loadMore": "加载更多", + "action.retry": "重试", + "details.account": "账号", + "details.channels": "联系方式", + "details.currentWorkspaceOnly": "仅属于当前工作区", + "details.delivery": "触达方式", + "details.emailOnly": "仅 Email", + "details.error": "无法加载联系人详情。", + "details.joined": "加入时间", + "details.loading": "正在加载联系人详情", + "details.membership": "成员状态", + "details.missing": "—", + "details.notDifyAccount": "不属于 Dify Account", + "details.notFound": "该联系人已不在当前工作区通讯录中。", + "details.organizationIdentity": "企业身份", + "details.profile": "联系人详情", + "details.sourceWorkspace": "来源工作区", + "details.title": "联系人详情", + "details.workspaceRole": "工作区角色", + "details.workspaceScope": "工作区", + "directory.addContact": "添加联系人", + "directory.addExternal": "添加外部联系人", + "directory.addFromPlatform": "从企业平台添加", + "directory.channel.email": "Email", + "directory.column.channels": "联系方式", + "directory.column.joined": "加入时间", + "directory.column.name": "名称", + "directory.column.type": "类型", + "directory.emptyDescription": "添加外部联系人,或邀请成员加入以便在联系人选择中使用。", + "directory.emptyTitle": "暂无联系人", + "directory.errorDescription": "请重试以读取当前工作区通讯录。", + "directory.errorTitle": "无法加载联系人", + "directory.filters": "联系人类型筛选", + "directory.learnMore": "了解 Contacts", + "directory.loading": "正在加载联系人", + "directory.noAccessDescription": "仅工作区所有者和管理员可以浏览完整联系人目录。", + "directory.noAccessTitle": "你没有 Contacts 访问权限", + "directory.noResultsDescription": "请尝试其他关键词或清除当前类型筛选。", + "directory.noResultsTitle": "没有匹配的联系人", + "directory.openDetails": "打开 {{name}} 的联系人详情", + "directory.pageError": "无法加载更多联系人,请重试当前页。", + "directory.search": "搜索名称或 Email", + "directory.title": "Contacts", + "directory.viewOnly": "你可以浏览 Contacts,但仅工作区所有者和管理员可以添加联系人。", + "external.add": "添加", + "external.adding": "正在添加", + "external.avatarFallback": "外部联系人", + "external.description": "创建仅属于当前工作区、通过 Email 触达的联系人。", + "external.email": "Email", + "external.name": "名称", + "external.result.duplicate_external_contact": "已存在使用此 Email 的外部联系人。", + "external.result.failed": "无法添加联系人,已保留填写内容,请重试。", + "external.result.matches_platform_contact": "此 Email 属于企业身份,请改为添加 Platform contact。", + "external.result.matches_workspace_contact": "该用户已经是工作区联系人。", + "external.title": "添加外部联系人", + "external.validation.email_invalid": "请输入有效的 Email 地址。", + "external.validation.email_required": "Email 为必填项。", + "external.validation.name_required": "名称为必填项。", + "filter.all": "全部", + "filter.external": "外部", + "filter.platform": "企业平台", + "filter.workspace": "工作区", "imPlatform.action.authorize": "授权", "imPlatform.action.authorizing": "正在授权", "imPlatform.action.configure": "配置", @@ -112,5 +177,27 @@ "imPlatform.sync.status.success": "成功", "imPlatform.sync.title": "通讯录同步", "imPlatform.sync.unsupported": "此 IM 平台不支持通讯录同步。", - "imPlatform.title": "IM 平台" + "imPlatform.title": "IM 平台", + "memberRemoval.eeDescription": "移除后该成员将失去工作区访问权限。请选择是否将同一联系人保留为 Platform contact。", + "memberRemoval.failed": "无法移除成员,当前数据未发生变化,请重试。", + "memberRemoval.keepPlatform": "保留为 Platform contact", + "memberRemoval.keepPlatformDescription": "移除工作区访问权限后,继续在 Contacts 中保留该身份。", + "memberRemoval.remove": "移除", + "memberRemoval.removing": "正在移除", + "memberRemoval.standardDescription": "移除此成员也会从当前 Contacts 目录中移除对应的工作区联系人。", + "memberRemoval.title": "从工作区移除 {{memberName}}?", + "organization.add": "添加联系人", + "organization.addFailed": "无法添加所选联系人,当前选择已保留。", + "organization.adding": "正在添加", + "organization.description": "选择尚未加入当前工作区 Contacts 的企业成员。", + "organization.empty": "没有符合搜索条件且可添加的企业成员。", + "organization.error": "无法加载企业成员。", + "organization.loading": "正在加载企业成员", + "organization.search": "搜索企业成员", + "organization.selectCandidate": "选择 {{name}}", + "organization.selected": "已选择 {{count}} 人", + "organization.title": "从企业平台添加", + "type.external": "外部", + "type.platform": "企业平台", + "type.workspace": "工作区" } diff --git a/web/scripts/check-i18n.js b/web/scripts/check-i18n.js index 78617fb9e1b..eff5a48a755 100644 --- a/web/scripts/check-i18n.js +++ b/web/scripts/check-i18n.js @@ -7,6 +7,9 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const targetLanguage = 'en-US' +const localizedFileLocales = { + contacts: new Set(['en-US', 'zh-Hans']), +} const languages = data.languages .filter((language) => language.supported) @@ -229,12 +232,16 @@ async function main() { ) : allLanguagesKeys - const keysCount = languagesKeys.map((keys) => keys.length) - const targetKeysCount = targetKeys.length + const getRequiredTargetKeys = (language) => + targetKeys.filter((key) => { + const fileName = key.split('.')[0] + const localizedLocales = localizedFileLocales[fileName] + return !localizedLocales || localizedLocales.has(language) + }) const comparison = languagesToProcess.reduce((result, language, index) => { - const languageKeysCount = keysCount[index] - const difference = targetKeysCount - languageKeysCount + const languageKeysCount = languagesKeys[index].length + const difference = getRequiredTargetKeys(language).length - languageKeysCount result[language] = difference return result }, {}) @@ -245,7 +252,9 @@ async function main() { for (let index = 0; index < languagesToProcess.length; index++) { const language = languagesToProcess[index] const languageKeys = languagesKeys[index] - const missingKeys = targetKeys.filter((key) => !languageKeys.includes(key)) + const missingKeys = getRequiredTargetKeys(language).filter( + (key) => !languageKeys.includes(key), + ) const extraKeys = languageKeys.filter((key) => !targetKeys.includes(key)) console.log(`Missing keys in ${language}:`, missingKeys)