diff --git a/auth0-myorganization-js-1.1.0.tgz b/auth0-myorganization-js-1.1.0.tgz new file mode 100644 index 000000000..8b2d13acd Binary files /dev/null and b/auth0-myorganization-js-1.1.0.tgz differ diff --git a/packages/core/package.json b/packages/core/package.json index 7912e9b80..30f1d07df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,7 +52,7 @@ }, "dependencies": { "@auth0/myaccount-js": "1.0.0-beta.0", - "@auth0/myorganization-js": "1.1.0", + "@auth0/myorganization-js": "file:../../auth0-myorganization-js-1.1.0.tgz", "zod": "^3.22.4" } } diff --git a/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts index e51228669..46e5d1bdd 100644 --- a/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts +++ b/packages/core/src/i18n/custom-messages/my-organization/member-management/invitation-tab-types.ts @@ -42,8 +42,11 @@ export interface OrganizationInvitationTabMessages { email_required_error?: string; roles_label?: string; roles_placeholder?: string; - provider_label?: string; - provider_placeholder?: string; + connection_label?: string; + connection_placeholder?: string; + connection_helper?: string; + connection_group_user_store?: string; + connection_group_identity_provider?: string; submit_button?: string; creating?: string; cancel_button?: string; @@ -53,7 +56,7 @@ export interface OrganizationInvitationTabMessages { email_label?: string; status_label?: string; roles_label?: string; - provider_label?: string; + connection_label?: string; created_at_label?: string; expires_at_label?: string; invited_by_label?: string; @@ -85,6 +88,7 @@ export interface OrganizationInvitationTabMessages { revoke_failed?: string; resend_failed?: string; revoke_resend_failed?: string; + connection_required?: string; copy_url_failed?: string; }; } diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index be1745590..9012b1cfc 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1266,9 +1266,11 @@ "email_required_error": "Please enter at least one email address.", "roles_label": "Roles", "roles_placeholder": "Select roles (optional)", - "provider_label": "Provider", - "provider_placeholder": "Select provider (optional)", - "provider_helper": "If none is selected, the member can log in with any provider.", + "connection_label": "Connection", + "connection_placeholder": "Select connection", + "connection_helper": "Select the connection the member will use to log in.", + "connection_group_user_store": "User Directory", + "connection_group_identity_provider": "Enterprise SSO", "submit_button": "Send Invite", "creating": "Creating...", "cancel_button": "Cancel", @@ -1280,7 +1282,7 @@ "email_label": "Email", "status_label": "Status", "roles_label": "Roles", - "provider_label": "Identity Provider", + "connection_label": "Connection", "created_at_label": "Created", "expires_at_label": "Expires", "invited_by_label": "Invited By", @@ -1313,6 +1315,7 @@ "revoke_failed": "Failed to revoke invitation. Please try again.", "resend_failed": "Failed to resend invitation. Please try again.", "revoke_resend_failed": "Failed to revoke and resend invitation. Please try again.", + "connection_required": "At least one of identity_provider_id or user_store_id must be provided.", "copy_url_failed": "Failed to copy invitation URL. Please try again." } } diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index a8122c20a..a7acf6e16 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1267,9 +1267,11 @@ "email_required_error": "メールアドレスを1つ以上入力してください。", "roles_label": "ロール", "roles_placeholder": "ロールを選択(任意)", - "provider_label": "プロバイダー", - "provider_placeholder": "プロバイダーを選択(任意)", - "provider_helper": "選択しない場合、メンバーは任意のプロバイダーでログインできます。", + "connection_label": "接続", + "connection_placeholder": "接続を選択", + "connection_helper": "メンバーがログインに使用する接続を選択してください。", + "connection_group_user_store": "ユーザーディレクトリ", + "connection_group_identity_provider": "エンタープライズSSO", "submit_button": "招待を送信", "creating": "作成中...", "cancel_button": "キャンセル", @@ -1281,7 +1283,7 @@ "email_label": "メール", "status_label": "ステータス", "roles_label": "ロール", - "provider_label": "IDプロバイダー", + "connection_label": "接続", "created_at_label": "作成日", "expires_at_label": "有効期限", "invited_by_label": "招待者", @@ -1314,6 +1316,7 @@ "revoke_failed": "招待の取り消しに失敗しました。もう一度お試しください。", "resend_failed": "招待の再送信に失敗しました。もう一度お試しください。", "revoke_resend_failed": "招待の取り消しと再送信に失敗しました。もう一度お試しください。", + "connection_required": "identity_provider_id または user_store_id のいずれか一方を指定する必要があります。", "copy_url_failed": "招待URLのコピーに失敗しました。もう一度お試しください。" } } diff --git a/packages/core/src/services/my-organization/member-management/member-management-constants.ts b/packages/core/src/services/my-organization/member-management/member-management-constants.ts index 763fe98d8..6174abfd8 100644 --- a/packages/core/src/services/my-organization/member-management/member-management-constants.ts +++ b/packages/core/src/services/my-organization/member-management/member-management-constants.ts @@ -12,6 +12,8 @@ export const memberManagementQueryKeys = { [...memberManagementQueryKeys.all, 'roles', 'search', term] as const, members: () => [...memberManagementQueryKeys.all, 'members'] as const, memberRoles: (id: string) => [...memberManagementQueryKeys.all, 'member-roles', id] as const, + identityProviders: () => [...memberManagementQueryKeys.all, 'identity-providers'] as const, + userStores: () => [...memberManagementQueryKeys.all, 'user-stores'] as const, organization: ['organization', 'details'] as const, }; diff --git a/packages/core/src/services/my-organization/member-management/member-management-types.ts b/packages/core/src/services/my-organization/member-management/member-management-types.ts index 3ef6a124e..fbaa0fc2f 100644 --- a/packages/core/src/services/my-organization/member-management/member-management-types.ts +++ b/packages/core/src/services/my-organization/member-management/member-management-types.ts @@ -126,3 +126,13 @@ export type RoleId = MyOrganization.RoleId; * Response content for listing organization roles. */ export type ListRolesResponseContent = MyOrganization.ListRolesResponseContent; + +/** + * User store available for routing invitations (e.g. an enterprise directory). + */ +export type UserStore = MyOrganization.UserStore; + +/** + * Response content for listing organization user stores. + */ +export type ListUserStoresResponseContent = MyOrganization.ListUserStoresResponseContent; diff --git a/packages/react/src/__tests__/utils/test-helpers.ts b/packages/react/src/__tests__/utils/test-helpers.ts index b5fa5d2bc..3b94ac73b 100644 --- a/packages/react/src/__tests__/utils/test-helpers.ts +++ b/packages/react/src/__tests__/utils/test-helpers.ts @@ -14,4 +14,11 @@ export function setupJsdomMocks(): void { if (typeof window !== 'undefined') { global.PointerEvent = MockPointerEvent as typeof PointerEvent; } + + // jsdom lacks these APIs that Radix primitives (e.g. Select) rely on to open. + if (typeof Element !== 'undefined') { + Element.prototype.scrollIntoView = Element.prototype.scrollIntoView ?? vi.fn(); + Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture ?? vi.fn(); + Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture ?? vi.fn(); + } } diff --git a/packages/react/src/components/auth0/my-organization/__tests__/domain-table.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.test.tsx index 0ac0763a5..d93221bbc 100644 --- a/packages/react/src/components/auth0/my-organization/__tests__/domain-table.test.tsx +++ b/packages/react/src/components/auth0/my-organization/__tests__/domain-table.test.tsx @@ -7,8 +7,6 @@ import * as useCoreClientModule from '@/hooks/shared/use-core-client'; import { createMockDomain, createMockVerifiedDomain, - createMockIdentityProvider, - createMockIdentityProviderAssociatedWithDomain, createMockDomainTableProps, createMockCreateAction, createMockVerifyAction, @@ -578,71 +576,6 @@ describe('DomainTable', () => { }); }); - describe('onOpenProvider', () => { - describe('when provider is clicked', () => { - it('should call onOpenProvider with provider details', async () => { - const user = userEvent.setup(); - const onOpenProvider = vi.fn(); - const provider = createMockIdentityProvider({ - id: 'con_provider_view', - display_name: 'View Provider', - name: 'view-provider', - }); - - const apiService = mockCoreClient.getMyOrganizationApiClient(); - ( - apiService.organization.identityProviders.list as ReturnType - ).mockResolvedValue({ - identity_providers: [provider], - }); - ( - apiService.organization.domains.identityProviders.get as ReturnType - ).mockResolvedValue({ - identity_providers: [ - createMockIdentityProviderAssociatedWithDomain({ - id: provider.id, - name: provider.name, - display_name: provider.display_name, - strategy: provider.strategy, - is_associated: true, - }), - ], - }); - - renderWithProviders(); - - await waitForComponentToLoad(); - - const verifiedBadge = await screen.findByText(/shared\.domain_statuses\.verified/i); - const verifiedRow = verifiedBadge.closest('tr'); - expect(verifiedRow).not.toBeNull(); - - const actionButton = within(verifiedRow as HTMLElement).getByRole('button'); - await user.click(actionButton); - - const configureMenuItem = await screen.findByRole('menuitem', { - name: /configure_button_text/i, - }); - await user.click(configureMenuItem); - - const configureModal = await screen.findByRole('dialog'); - const viewProviderButton = await within(configureModal).findByRole('button', { - name: /view_provider_button_text/i, - }); - await user.click(viewProviderButton); - - expect(onOpenProvider).toHaveBeenCalledTimes(1); - expect(onOpenProvider).toHaveBeenCalledWith( - expect.objectContaining({ - id: provider.id, - name: provider.name, - display_name: provider.display_name, - }), - ); - }); - }); - }); - describe('onCreateProvider', () => { describe('when create provider is clicked', () => { it('should call onCreateProvider', async () => { diff --git a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx index b75dfb124..efb3384ed 100644 --- a/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx +++ b/packages/react/src/components/auth0/my-organization/__tests__/organization-member-management.test.tsx @@ -213,7 +213,7 @@ const createMockMemberManagementResult = ( availableRoles: createMockRoleOptions(), searchedRoles: createMockRoleOptions(), onRoleSearch: vi.fn(), - availableProviders: [], + availableConnections: [], members: [member], invitations: [invitation], isFetchingInvitations: false, diff --git a/packages/react/src/components/auth0/my-organization/organization-member-management.tsx b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx index fb8a2134c..a09cf2518 100644 --- a/packages/react/src/components/auth0/my-organization/organization-member-management.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx @@ -65,7 +65,7 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage availableRoles, searchedRoles, onRoleSearch, - availableProviders, + availableConnections, modalState, membersUpdatedAt, invitationsUpdatedAt, @@ -246,7 +246,7 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage isLoading={isCreatingInvitation} customMessages={customMessages?.invitation} availableRoles={searchedRoles} - availableProviders={availableProviders} + availableConnections={availableConnections} style={currentStyles.variables} onClose={closeModal} onCreate={handleCreateSubmit} @@ -261,7 +261,7 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage isResending={isResendingInvitation} customMessages={customMessages?.invitation} availableRoles={availableRoles} - availableProviders={availableProviders} + availableConnections={availableConnections} readOnly={readOnly} style={currentStyles.variables} onClose={closeModal} diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/__tests__/organization-invitation-details-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/__tests__/organization-invitation-details-modal.test.tsx index 156aac186..25e1fb57d 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/__tests__/organization-invitation-details-modal.test.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/__tests__/organization-invitation-details-modal.test.tsx @@ -9,7 +9,7 @@ import { createMockPendingInvitation, createMockExpiredInvitation, createMockRoles, - createMockProviders, + createMockConnections, } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; import { renderWithProviders, TestProvider } from '@/tests/utils/test-provider'; @@ -195,40 +195,63 @@ describe('OrganizationInvitationDetailsModal', () => { }); }); - describe('identity provider', () => { - it('should display provider name when resolved', () => { + describe('connection', () => { + it('should display identity provider name when resolved', () => { const invitation = createMockInvitation({ identity_provider_id: 'con_provider1' }); - const availableProviders = createMockProviders(); renderWithProviders( , ); expect(screen.getByDisplayValue('Google')).toBeInTheDocument(); }); - it('should show provider ID as fallback when provider not found', () => { + it('should display user store name when resolved', () => { + const invitation = createMockInvitation({ + identity_provider_id: undefined, + user_store_id: 'us_store1', + }); + + renderWithProviders( + , + ); + + expect(screen.getByDisplayValue('Acme Directory')).toBeInTheDocument(); + }); + + it('should show connection ID as fallback when connection not found', () => { const invitation = createMockInvitation({ identity_provider_id: 'con_unknown' }); renderWithProviders( , ); expect(screen.getByDisplayValue('con_unknown')).toBeInTheDocument(); }); - it('should not display provider section when no provider assigned', () => { - const invitation = createMockInvitation({ identity_provider_id: undefined }); + it('should not display connection section when no connection assigned', () => { + const invitation = createMockInvitation({ + identity_provider_id: undefined, + user_store_id: undefined, + }); renderWithProviders( , ); - expect(screen.queryByText('invitation.details.provider_label')).not.toBeInTheDocument(); + expect(screen.queryByText('invitation.details.connection_label')).not.toBeInTheDocument(); }); }); diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx index e874b372c..01f245137 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal.tsx @@ -45,7 +45,7 @@ function getStatusBadgeVariant(status: InvitationStatus): 'warning' | 'destructi * @param props.isResending - Whether a resend action is in progress. * @param props.customMessages - Custom translation messages. * @param props.availableRoles - Available roles for display. - * @param props.availableProviders - Available providers for display. + * @param props.availableConnections - Merged identity providers + user stores for resolving the connection name. * @param props.readOnly - Whether in read-only mode. * @param props.onClose - Callback when modal is closed. * @param props.onCopyUrl - Callback when copy URL is clicked. @@ -62,7 +62,7 @@ export function OrganizationInvitationDetailsModal({ isResending = false, customMessages = {}, availableRoles = [], - availableProviders = [], + availableConnections = [], readOnly = false, onClose, onCopyUrl, @@ -87,11 +87,12 @@ export function OrganizationInvitationDetailsModal({ .filter(Boolean); }, [invitation?.roles, availableRoles]); - const providerName = React.useMemo(() => { - if (!invitation?.identity_provider_id) return null; - const provider = availableProviders.find((p) => p.id === invitation.identity_provider_id); - return provider?.name ?? invitation.identity_provider_id; - }, [invitation?.identity_provider_id, availableProviders]); + const connectionName = React.useMemo(() => { + const connectionId = invitation?.identity_provider_id ?? invitation?.user_store_id; + if (!connectionId) return null; + const connection = availableConnections.find((c) => c.id === connectionId); + return connection?.name ?? connectionId; + }, [invitation?.identity_provider_id, invitation?.user_store_id, availableConnections]); const [copied, setCopied] = React.useState(false); const copyTimeoutRef = React.useRef | null>(null); @@ -255,13 +256,13 @@ export function OrganizationInvitationDetailsModal({ - {/* Identity Provider */} - {providerName && ( + {/* Connection (identity provider or user directory) */} + {connectionName && (
- +
)} diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/__tests__/organization-invitation-create-modal.test.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/__tests__/organization-invitation-create-modal.test.tsx index 6955f93a2..8a80b7749 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/__tests__/organization-invitation-create-modal.test.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/__tests__/organization-invitation-create-modal.test.tsx @@ -6,7 +6,7 @@ import { OrganizationInvitationCreateModal } from '@/components/auth0/my-organiz import { createMockCreateModalProps, createMockRoles, - createMockProviders, + createMockConnections, } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; import { renderWithProviders } from '@/tests/utils/test-provider'; @@ -210,31 +210,138 @@ describe('OrganizationInvitationCreateModal', () => { }); }); - describe('availableProviders', () => { - describe('when providers are provided', () => { - it('should render provider dropdown', () => { - renderWithProviders( - , - ); + describe('availableConnections', () => { + const addEmailAndSubmit = async (onCreate: ReturnType) => { + const emailInput = screen.getByPlaceholderText('invitation.create.email_placeholder'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + fireEvent.keyDown(emailInput, { key: 'Enter' }); - expect(screen.getByText('invitation.create.provider_label')).toBeInTheDocument(); + const submitButton = screen.getByRole('button', { + name: 'invitation.create.submit_button', }); + await userEvent.click(submitButton); + + expect(onCreate).toHaveBeenCalledTimes(1); + return onCreate.mock.calls[0]![0]; + }; + + it('should render both identity providers and user stores in the picker', async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole('combobox')); + + expect(screen.getByText('Google')).toBeInTheDocument(); + expect(screen.getByText('Acme Directory')).toBeInTheDocument(); }); - describe('when no providers are provided', () => { - it('should still render provider section', () => { - renderWithProviders( - , - ); + it('should render user store and identity provider group headers', async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole('combobox')); + + expect(screen.getByText('invitation.create.connection_group_user_store')).toBeInTheDocument(); + expect( + screen.getByText('invitation.create.connection_group_identity_provider'), + ).toBeInTheDocument(); + }); + + it('should submit identity_provider_id when an identity provider is selected', async () => { + const user = userEvent.setup(); + const onCreate = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Google')); - expect(screen.getByText('invitation.create.provider_label')).toBeInTheDocument(); + const payload = await addEmailAndSubmit(onCreate); + expect(payload.identity_provider_id).toBe('con_provider1'); + expect(payload.user_store_id).toBeUndefined(); + }); + + it('should submit user_store_id when a user store is selected', async () => { + const user = userEvent.setup(); + const onCreate = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Acme Directory')); + + const payload = await addEmailAndSubmit(onCreate); + expect(payload.user_store_id).toBe('us_store1'); + expect(payload.identity_provider_id).toBeUndefined(); + }); + + it('should still render the connection section when no connections are provided', () => { + renderWithProviders( + , + ); + + expect(screen.getByText(/invitation\.create\.connection_label/)).toBeInTheDocument(); + }); + + it('should keep submit disabled until a connection is selected', async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + const emailInput = screen.getByPlaceholderText('invitation.create.email_placeholder'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + fireEvent.keyDown(emailInput, { key: 'Enter' }); + + const submitButton = screen.getByRole('button', { + name: 'invitation.create.submit_button', }); + expect(submitButton).toBeDisabled(); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Google')); + + expect(submitButton).toBeEnabled(); + }); + + it('should render the connection label as required', () => { + renderWithProviders( + , + ); + + expect(screen.getByText(/invitation\.create\.connection_label/)).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-required', 'true'); }); }); diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx index fbc57b94d..5dd5515ac 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal.tsx @@ -20,7 +20,9 @@ import { Label } from '@/components/ui/label'; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from '@/components/ui/select'; @@ -39,7 +41,7 @@ import type { OrganizationInvitationCreateModalProps } from '@/types/my-organiza * @param props.isLoading - Whether the form is loading. * @param props.customMessages - Custom translation messages. * @param props.availableRoles - Available roles for selection. - * @param props.availableProviders - Available identity providers. + * @param props.availableConnections - Merged identity providers + user stores for the picker. * @param props.inviterName - Name of the person sending the invitation. * @param props.schema - Schema overrides for validation (email regex, maxEmails, error messages). * @param props.onClose - Callback when modal is closed. @@ -53,7 +55,7 @@ export function OrganizationInvitationCreateModal({ isLoading = false, customMessages = {}, availableRoles = [], - availableProviders = [], + availableConnections = [], inviterName, schema, onClose, @@ -64,6 +66,16 @@ export function OrganizationInvitationCreateModal({ }: OrganizationInvitationCreateModalProps): React.JSX.Element { const { t } = useTranslator('member_management', customMessages); + const { userStoreConnections, identityProviderConnections } = React.useMemo( + () => ({ + userStoreConnections: availableConnections.filter((c) => c.type === 'user_store'), + identityProviderConnections: availableConnections.filter( + (c) => c.type === 'identity_provider', + ), + }), + [availableConnections], + ); + const validationConfig = React.useMemo( () => createInvitationCreateSchema(schema, t('invitation.create.email_invalid_error')), [schema, t], @@ -72,14 +84,14 @@ export function OrganizationInvitationCreateModal({ const [emailInput, setEmailInput] = React.useState(''); const [emailChips, setEmailChips] = React.useState([]); const [selectedRoles, setSelectedRoles] = React.useState([]); - const [selectedProvider, setSelectedProvider] = React.useState(); + const [selectedConnectionId, setSelectedConnectionId] = React.useState(); const [emailError, setEmailError] = React.useState(); const resetForm = React.useCallback(() => { setEmailInput(''); setEmailChips([]); setSelectedRoles([]); - setSelectedProvider(undefined); + setSelectedConnectionId(undefined); setEmailError(undefined); onRoleSearch?.(''); }, [onRoleSearch]); @@ -148,8 +160,8 @@ export function OrganizationInvitationCreateModal({ setSelectedRoles(Array.isArray(value) ? value : value ? [value] : []); }, []); - const handleProviderChange = React.useCallback((value: string) => { - setSelectedProvider(value || undefined); + const handleConnectionChange = React.useCallback((value: string) => { + setSelectedConnectionId(value || undefined); }, []); const handleSubmit = React.useCallback(() => { @@ -173,12 +185,24 @@ export function OrganizationInvitationCreateModal({ return; } + const selectedConnection = availableConnections.find((c) => c.id === selectedConnectionId); + + if (!selectedConnection) { + return; + } + + const user_store_id = + selectedConnection.type === 'user_store' ? selectedConnection.id : undefined; + const identity_provider_id = + selectedConnection.type === 'identity_provider' ? selectedConnection.id : undefined; + onCreate({ invitees: finalEmails.map((email) => ({ email, roles: selectedRoles.length > 0 ? selectedRoles : undefined, })), - identity_provider_id: selectedProvider, + user_store_id, + identity_provider_id, ...(inviterName && { inviter: { name: inviterName } }), }); }, [ @@ -186,7 +210,8 @@ export function OrganizationInvitationCreateModal({ emailInput, validationConfig, selectedRoles, - selectedProvider, + selectedConnectionId, + availableConnections, inviterName, onCreate, t, @@ -200,10 +225,11 @@ export function OrganizationInvitationCreateModal({ const canSubmit = React.useMemo( () => !hasInvalidChips && + !!selectedConnectionId && (emailChips.length > 0 || (emailInput.trim() !== '' && validationConfig.emailSchema.safeParse(emailInput.trim()).success)), - [emailChips.length, emailInput, validationConfig, hasInvalidChips], + [emailChips.length, emailInput, validationConfig, hasInvalidChips, selectedConnectionId], ); const roleOptions = React.useMemo( @@ -254,25 +280,42 @@ export function OrganizationInvitationCreateModal({
- + -

- {t('invitation.create.provider_helper')} +

+ {t('invitation.create.connection_helper')}

diff --git a/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts b/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts index 06b6d1455..1561d6025 100644 --- a/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts +++ b/packages/react/src/hooks/my-organization/__tests__/use-member-management-service.test.ts @@ -97,6 +97,83 @@ describe('useMemberManagementService', () => { expect(result.current.providersQuery.fetchStatus).toBe('idle'); }); + + it('should map identity providers to connection options tagged as identity_provider', async () => { + mockCoreClient.getMyOrganizationApiClient().organization.identityProviders.list = vi + .fn() + .mockResolvedValue({ + identity_providers: [ + { id: 'con_1', display_name: 'Google', name: 'google', strategy: 'social' }, + { id: 'con_2', name: 'okta', strategy: 'enterprise' }, + { name: 'no-id', strategy: 'social' }, + ], + }); + + const options = createDefaultOptions(); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.providersQuery.isSuccess).toBe(true); + }); + + expect(result.current.providersQuery.data).toEqual([ + { id: 'con_1', name: 'Google', type: 'identity_provider' }, + { id: 'con_2', name: 'okta', type: 'identity_provider' }, + ]); + }); + }); + + describe('userStoresQuery', () => { + const userStoresGetMock = () => + mockCoreClient.getMyOrganizationApiClient().organization.userStores.get; + + it('should fetch user stores when a tab is active', async () => { + const options = createDefaultOptions({ activeTab: 'invitations' }); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.userStoresQuery.isSuccess).toBe(true); + }); + + expect(userStoresGetMock()).toHaveBeenCalled(); + }); + + it('should not fetch user stores when no active tab is provided', async () => { + const options = createDefaultOptions({ activeTab: undefined }); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.rolesQuery.isSuccess).toBe(true); + }); + + expect(result.current.userStoresQuery.fetchStatus).toBe('idle'); + }); + + it('should map user stores to connection options tagged as user_store', async () => { + mockCoreClient.getMyOrganizationApiClient().organization.userStores.get = vi + .fn() + .mockResolvedValue({ + user_stores: [ + { id: 'us_1', display_name: 'Acme Directory', name: 'acme' }, + { id: 'us_2', name: 'okta-store' }, + { id: 'us_3' }, + ], + next: null, + }); + + const options = createDefaultOptions(); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.userStoresQuery.isSuccess).toBe(true); + }); + + expect(result.current.userStoresQuery.data).toEqual([ + { id: 'us_1', name: 'Acme Directory', type: 'user_store' }, + { id: 'us_2', name: 'okta-store', type: 'user_store' }, + { id: 'us_3', name: 'us_3', type: 'user_store' }, + ]); + }); }); describe('rolesQuery', () => { @@ -334,6 +411,26 @@ describe('useMemberManagementService', () => { expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); }); + it('should forward user_store_id to the create request', async () => { + const options = createDefaultOptions(); + const { result } = renderService(options); + + await act(async () => { + result.current.createInvitationMutation.mutate({ + invitees: [{ email: 'new@example.com' }], + user_store_id: 'us_1', + }); + }); + + await waitFor(() => { + expect(result.current.createInvitationMutation.isSuccess).toBe(true); + }); + + expect( + mockCoreClient.getMyOrganizationApiClient().organization.invitations.create, + ).toHaveBeenCalledWith(expect.objectContaining({ user_store_id: 'us_1' })); + }); + it('should call onBefore action and cancel if it returns false', async () => { const onBefore = vi.fn().mockReturnValue(false); const options = createDefaultOptions({ @@ -463,8 +560,11 @@ describe('useMemberManagementService', () => { }); describe('resendInvitationMutation', () => { - it('should revoke and resend an invitation', async () => { - const invitation = createMockInvitation(); + it('should revoke and resend an invitation, preserving the connection', async () => { + const invitation = createMockInvitation({ identity_provider_id: 'con_provider1' }); + const orgApi = mockCoreClient.getMyOrganizationApiClient().organization; + orgApi.invitations.get = vi.fn().mockResolvedValue(invitation); + const options = createDefaultOptions(); const { result } = renderService(options); @@ -476,13 +576,38 @@ describe('useMemberManagementService', () => { expect(result.current.resendInvitationMutation.isSuccess).toBe(true); }); - const orgApi = mockCoreClient.getMyOrganizationApiClient().organization; expect(orgApi.invitations.get).toHaveBeenCalledWith(invitation.id); expect(orgApi.invitations.delete).toHaveBeenCalled(); - expect(orgApi.invitations.create).toHaveBeenCalled(); + expect(orgApi.invitations.create).toHaveBeenCalledWith( + expect.objectContaining({ + identity_provider_id: 'con_provider1', + user_store_id: undefined, + }), + ); expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); }); + it('should not delete a legacy invitation lacking both connection identifiers', async () => { + const invitation = createMockInvitation(); + const orgApi = mockCoreClient.getMyOrganizationApiClient().organization; + orgApi.invitations.get = vi.fn().mockResolvedValue(invitation); + + const options = createDefaultOptions(); + const { result } = renderService(options); + + await act(async () => { + result.current.resendInvitationMutation.mutate(invitation); + }); + + await waitFor(() => { + expect(result.current.resendInvitationMutation.isError).toBe(true); + }); + + expect(orgApi.invitations.delete).not.toHaveBeenCalled(); + expect(orgApi.invitations.create).not.toHaveBeenCalled(); + expect(mockedShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + }); + it('should call onBefore action and cancel if it returns false', async () => { const onBefore = vi.fn().mockReturnValue(false); const invitation = createMockInvitation(); diff --git a/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts index dcdf46ec3..529166fde 100644 --- a/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts +++ b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts @@ -26,7 +26,10 @@ import { } from '@/lib/constants/my-organization/member-management/member-management-constants'; import { validateRequestRoleForMember } from '@/lib/utils/my-organization/member-management/member-management-utils'; import { getPreviousDataOption } from '@/lib/utils/tanstack-compat'; -import type { CreateInvitationInput } from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + ConnectionOption, + CreateInvitationInput, +} from '@/types/my-organization/member-management/organization-invitation-table-types'; import type { UseMemberManagementServiceOptions, MemberManagementServiceResult, @@ -85,18 +88,38 @@ export function useMemberManagementService( const handleError = useErrorHandler(); const queryClient = useQueryClient(); - const providersQuery = useQuery({ - queryKey: [...memberManagementQueryKeys.all, 'identity-providers'], + const providersQuery = useQuery({ + queryKey: memberManagementQueryKeys.identityProviders(), queryFn: async () => { const response: ListIdentityProvidersResponseContent = await coreClient! .getMyOrganizationApiClient() .organization.identityProviders.list(); const providers = response.identity_providers ?? []; - return providers.map((p) => ({ - id: p.id!, - name: p.display_name ?? p.name ?? '', - type: p.strategy, - })); + return providers + .filter((p) => !!p.id) + .map((p) => ({ + id: p.id!, + name: p.display_name ?? p.name ?? p.id!, + type: 'identity_provider' as const, + })); + }, + enabled: !!coreClient && isActiveTabProvided, + }); + + const userStoresQuery = useQuery({ + queryKey: memberManagementQueryKeys.userStores(), + queryFn: async () => { + const page = await coreClient! + .getMyOrganizationApiClient() + .organization.userStores.get({ is_enabled: true }); + const userStores = page.user_stores ?? []; + return userStores + .filter((store) => !!store.id) + .map((store) => ({ + id: store.id!, + name: store.display_name ?? store.name ?? store.id!, + type: 'user_store' as const, + })); }, enabled: !!coreClient && isActiveTabProvided, }); @@ -280,6 +303,7 @@ export function useMemberManagementService( invitees: data.invitees, inviter: data.inviter, identity_provider_id: data.identity_provider_id, + user_store_id: data.user_store_id, ttl_sec: data.ttl_sec, }); return Array.isArray(response) ? response[0] : response; @@ -329,6 +353,13 @@ export function useMemberManagementService( const freshInvitation = await coreClient! .getMyOrganizationApiClient() .organization.invitations.get(invitation.id!); + const identityProviderId = + freshInvitation.identity_provider_id ?? invitation.identity_provider_id; + const userStoreId = freshInvitation.user_store_id ?? invitation.user_store_id; + + if (!identityProviderId && !userStoreId) { + throw new Error(t('invitation.error.connection_required')); + } await coreClient! .getMyOrganizationApiClient() .organization.invitations.delete(freshInvitation.id ?? invitation.id!); @@ -338,6 +369,8 @@ export function useMemberManagementService( .getMyOrganizationApiClient() .organization.invitations.create({ invitees: [{ email, roles }], + identity_provider_id: identityProviderId, + user_store_id: userStoreId, }); return Array.isArray(response) ? response[0] : response; }, @@ -366,6 +399,7 @@ export function useMemberManagementService( return { providersQuery, + userStoresQuery, rolesQuery, rolesSearchQuery, setRoleSearchTerm, diff --git a/packages/react/src/hooks/my-organization/use-organization-member-management.ts b/packages/react/src/hooks/my-organization/use-organization-member-management.ts index 8d3a521f9..0fc6588b1 100644 --- a/packages/react/src/hooks/my-organization/use-organization-member-management.ts +++ b/packages/react/src/hooks/my-organization/use-organization-member-management.ts @@ -13,8 +13,8 @@ import { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-paginatio import { useTranslator } from '@/hooks/shared/use-translator'; import { isMutationLoading } from '@/lib/utils/tanstack-compat'; import type { + ConnectionOption, CreateInvitationInput, - IdentityProviderOption, } from '@/types/my-organization/member-management/organization-invitation-table-types'; import type { ActiveTab, @@ -83,6 +83,7 @@ export function useOrganizationMemberManagement( const { providersQuery, + userStoresQuery, rolesQuery, rolesSearchQuery, setRoleSearchTerm, @@ -125,7 +126,10 @@ export function useOrganizationMemberManagement( } }, [modalState.type, enableRoleSearch]); - const availableProviders: IdentityProviderOption[] = providersQuery.data ?? []; + const availableConnections: ConnectionOption[] = React.useMemo( + () => [...(providersQuery.data ?? []), ...(userStoresQuery.data ?? [])], + [providersQuery.data, userStoresQuery.data], + ); const availableRoles = rolesQuery.data ?? []; const searchedRoles = rolesSearchQuery.data ?? []; const currentInvitations = invitationsQuery.data?.invitations ?? []; @@ -279,7 +283,7 @@ export function useOrganizationMemberManagement( availableRoles, searchedRoles, onRoleSearch: setRoleSearchTerm, - availableProviders, + availableConnections, invitations: currentInvitations, members: currentMembers, diff --git a/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts b/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts index 5934334fa..3a5e21283 100644 --- a/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/core/core-client.mocks.ts @@ -8,7 +8,10 @@ import { createMockEmptyAuthenticationMethods, } from '@/tests/utils/__mocks__/my-account/mfa/mfa.mocks'; import { createMockIdentityProvider } from '@/tests/utils/__mocks__/my-organization/domain-management/domain.mocks'; -import { createMockInvitation } from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; +import { + createMockInvitation, + createMockListUserStoresResponse, +} from '@/tests/utils/__mocks__/my-organization/member-management/invitation.mocks'; import { createMockOrganization } from '@/tests/utils/__mocks__/my-organization/organization-management/organization-details.mocks'; const createMockMyAccountApiService = (): CoreClientInterface['myAccountApiClient'] => { @@ -66,6 +69,9 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie create: vi.fn().mockResolvedValue([createMockInvitation()]), delete: vi.fn().mockResolvedValue(undefined), }, + userStores: { + get: vi.fn().mockResolvedValue(createMockListUserStoresResponse()), + }, roles: { list: vi.fn().mockResolvedValue({ data: [{ id: 'rol_admin', name: 'admin', description: 'Admin role' }], diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts index 1dbc318ec..ca6e51808 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/member-management/invitation.mocks.ts @@ -1,8 +1,13 @@ -import type { MemberInvitation, Role } from '@auth0/universal-components-core'; +import type { + ListUserStoresResponseContent, + MemberInvitation, + Role, + UserStore, +} from '@auth0/universal-components-core'; import { vi } from 'vitest'; import type { - IdentityProviderOption, + ConnectionOption, OrganizationInvitationCreateModalProps, OrganizationInvitationDetailsModalProps, OrganizationInvitationRevokeModalProps, @@ -38,15 +43,29 @@ export const createMockExpiredInvitation = ( ...overrides, }); +export const createMockUserStore = (overrides?: Partial): UserStore => ({ + id: 'us_store1', + name: 'acme-directory', + display_name: 'Acme Directory', + ...overrides, +}); + +export const createMockListUserStoresResponse = ( + stores: UserStore[] = [createMockUserStore()], +): ListUserStoresResponseContent => ({ + user_stores: stores, + next: undefined, +}); + export const createMockRoles = (): Role[] => [ { id: 'role_admin', name: 'Admin', description: 'Administrator role' }, { id: 'role_member', name: 'Member', description: 'Member role' }, { id: 'role_viewer', name: 'Viewer', description: 'Viewer role' }, ]; -export const createMockProviders = (): IdentityProviderOption[] => [ - { id: 'con_provider1', name: 'Google', type: 'social' }, - { id: 'con_provider2', name: 'Okta', type: 'enterprise' }, +export const createMockConnections = (): ConnectionOption[] => [ + { id: 'con_provider1', name: 'Google', type: 'identity_provider' }, + { id: 'us_store1', name: 'Acme Directory', type: 'user_store' }, ]; export const createMockCreateModalProps = ( diff --git a/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts b/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts index b0f5a60a3..9bc4041c8 100644 --- a/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts +++ b/packages/react/src/types/my-organization/member-management/organization-invitation-table-types.ts @@ -22,11 +22,14 @@ import type { /** Invitation status. */ export type InvitationStatus = 'pending' | 'expired'; -/** Identity provider option for invitation. */ -export interface IdentityProviderOption { +/** Source that a connection option originates from. */ +export type ConnectionOptionType = 'identity_provider' | 'user_store'; + +/** A selectable connection in the invitation picker. */ +export interface ConnectionOption { id: string; name: string; - type?: string; + type: ConnectionOptionType; } /** Input for creating invitation(s). Supports bulk invite via invitees array. */ @@ -39,6 +42,7 @@ export interface CreateInvitationInput { name?: string; }; identity_provider_id?: string; + user_store_id?: string; /** Time to live in seconds */ ttl_sec?: number; } @@ -117,7 +121,7 @@ export interface OrganizationInvitationCreateModalProps { isLoading?: boolean; customMessages?: Partial; availableRoles?: Role[]; - availableProviders?: IdentityProviderOption[]; + availableConnections?: ConnectionOption[]; inviterName?: string; schema?: InvitationCreateSchemas; style?: React.CSSProperties; @@ -135,7 +139,7 @@ export interface OrganizationInvitationDetailsModalProps { isResending?: boolean; customMessages?: Partial; availableRoles?: Role[]; - availableProviders?: IdentityProviderOption[]; + availableConnections?: ConnectionOption[]; readOnly?: boolean; style?: React.CSSProperties; onClose: () => void; diff --git a/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts index 79b24a5ca..af4ed97df 100644 --- a/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts +++ b/packages/react/src/types/my-organization/member-management/organization-member-management-types.ts @@ -15,8 +15,8 @@ import type { import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import type { + ConnectionOption, CreateInvitationInput, - IdentityProviderOption, OrganizationInvitationTabClasses, } from './organization-invitation-table-types'; @@ -71,7 +71,8 @@ export interface UseMemberManagementServiceOptions { } export interface MemberManagementServiceResult { - providersQuery: UseQueryResult; + providersQuery: UseQueryResult; + userStoresQuery: UseQueryResult; rolesQuery: UseQueryResult; rolesSearchQuery: UseQueryResult; setRoleSearchTerm: (term: string) => void; @@ -141,7 +142,7 @@ export interface UseOrganizationMemberManagementResult { availableRoles: Role[]; searchedRoles: Role[]; onRoleSearch: (term: string) => void; - availableProviders: IdentityProviderOption[]; + availableConnections: ConnectionOption[]; members: OrgMember[]; invitations: MemberInvitation[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 615488178..27c066f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,8 +548,8 @@ importers: specifier: 1.0.0-beta.0 version: 1.0.0-beta.0 '@auth0/myorganization-js': - specifier: 1.1.0 - version: 1.1.0 + specifier: file:../../auth0-myorganization-js-1.1.0.tgz + version: file:auth0-myorganization-js-1.1.0.tgz zod: specifier: ^3.22.4 version: 3.25.76 @@ -733,8 +733,9 @@ packages: resolution: {integrity: sha512-slj0RtNfieNk1BC1ERrCQw65qMUVKU5qacbTc8BFH8R316CUpsOhZ2MIiV9l3VEkaqY1hmCZm03+ZI6ym+3PZg==} engines: {node: '>=18.0.0'} - '@auth0/myorganization-js@1.1.0': - resolution: {integrity: sha512-DG3/PDDPB2ayuXRrNZOdBTPdqT1O6GKahW3U+i75UQn27rJGIkIVvonx/IEOUxFdo+gJs9zwlpUt/kdD0ICEiA==} + '@auth0/myorganization-js@file:auth0-myorganization-js-1.1.0.tgz': + resolution: {integrity: sha512-AtipGDJOsc7JugT8n/zyC9wupdpCZwwzj98outOYFkAi8QiumsHS8KjDhMD91++9Fmh1Y7yvzphVeFLG+N+BAw==, tarball: file:auth0-myorganization-js-1.1.0.tgz} + version: 1.1.0 engines: {node: '>=20.0.0'} '@auth0/nextjs-auth0@4.21.0': @@ -7522,7 +7523,7 @@ snapshots: '@auth0/myaccount-js@1.0.0-beta.0': {} - '@auth0/myorganization-js@1.1.0': + '@auth0/myorganization-js@file:auth0-myorganization-js-1.1.0.tgz': dependencies: '@auth0/auth0-auth-js': 1.5.0