diff --git a/auth0-myorganization-js-1.0.0.tgz b/auth0-myorganization-js-1.0.0.tgz new file mode 100644 index 000000000..c9521ca93 Binary files /dev/null and b/auth0-myorganization-js-1.0.0.tgz differ diff --git a/examples/next-rwa/src/app/member-management/page.tsx b/examples/next-rwa/src/app/member-management/page.tsx index ab39b4930..311056e13 100644 --- a/examples/next-rwa/src/app/member-management/page.tsx +++ b/examples/next-rwa/src/app/member-management/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { OrganizationMemberManagement } from '@auth0/universal-components-react'; +// import { OrganizationMemberManagement } from '@auth0/universal-components-react'; export default function MemberManagementPage() { return ( @@ -15,7 +15,7 @@ export default function MemberManagementPage() { {' '} on how to add Member Management component.

- + {/* */} ); } diff --git a/examples/next-rwa/src/components/navigation/side-bar.tsx b/examples/next-rwa/src/components/navigation/side-bar.tsx index 0eb2407d3..a2d781018 100644 --- a/examples/next-rwa/src/components/navigation/side-bar.tsx +++ b/examples/next-rwa/src/components/navigation/side-bar.tsx @@ -80,7 +80,7 @@ export const Sidebar: React.FC = () => { className="flex items-center gap-3 px-3 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md dark:text-gray-300 dark:hover:text-white dark:hover:bg-gray-800 transition-colors" > - {t('sidebar.members')} + {t('sidebar.member-management')} diff --git a/examples/next-rwa/src/providers/i18n-provider.tsx b/examples/next-rwa/src/providers/i18n-provider.tsx index 5312cc407..5ef17827b 100644 --- a/examples/next-rwa/src/providers/i18n-provider.tsx +++ b/examples/next-rwa/src/providers/i18n-provider.tsx @@ -26,7 +26,7 @@ i18n.use(initReactI18next).init({ 'sidebar.organization-settings': 'Organization Settings', 'sidebar.domains': 'Domains', 'sidebar.identity-providers': 'Identity Providers', - 'sidebar.members': 'Members', + 'sidebar.member-management': 'Members', }, }, }, diff --git a/examples/react-spa-npm/src/locales/en.json b/examples/react-spa-npm/src/locales/en.json index bb2872360..728b4d821 100644 --- a/examples/react-spa-npm/src/locales/en.json +++ b/examples/react-spa-npm/src/locales/en.json @@ -22,6 +22,7 @@ "my-organization": "My Organization", "organization-management": "Organization Management", "sso-provider": "SSO Provider", - "domain-management": "Domain Management" + "domain-management": "Domain Management", + "member-management": "Members" } } diff --git a/examples/react-spa-npm/src/locales/ja.json b/examples/react-spa-npm/src/locales/ja.json index 543960731..cc9eaef15 100644 --- a/examples/react-spa-npm/src/locales/ja.json +++ b/examples/react-spa-npm/src/locales/ja.json @@ -22,6 +22,7 @@ "my-organization": "マイ組織", "organization-management": "組織管理", "sso-provider": "SSOプロバイダー", - "domain-management": "ドメイン管理" + "domain-management": "ドメイン管理", + "member-management": "メンバー" } } diff --git a/examples/react-spa-npm/src/views/member-management-page.tsx b/examples/react-spa-npm/src/views/member-management-page.tsx index e9667f79d..057a2569a 100644 --- a/examples/react-spa-npm/src/views/member-management-page.tsx +++ b/examples/react-spa-npm/src/views/member-management-page.tsx @@ -1,4 +1,4 @@ -// import { OrganizationMemberManagement } from '@auth0/universal-components-react/spa'; +// import { OrganizationMemberManagement } from '@auth0/universal-components-react'; const MemberManagementPage = () => { return ( diff --git a/examples/react-spa-shadcn/src/locales/en.json b/examples/react-spa-shadcn/src/locales/en.json index c3c5bb9fc..cbede71c0 100644 --- a/examples/react-spa-shadcn/src/locales/en.json +++ b/examples/react-spa-shadcn/src/locales/en.json @@ -26,7 +26,8 @@ "my-organization": "My Organization", "organization-management": "Organization Management", "identity-provider-management": "Identity Provider Management", - "domain-management": "Domain Management" + "domain-management": "Domain Management", + "member-management": "Members" }, "mfa": { "title": "Multi-Factor Authentication" diff --git a/examples/react-spa-shadcn/src/locales/ja.json b/examples/react-spa-shadcn/src/locales/ja.json index 39a4140d4..07db77747 100644 --- a/examples/react-spa-shadcn/src/locales/ja.json +++ b/examples/react-spa-shadcn/src/locales/ja.json @@ -26,7 +26,8 @@ "my-organization": "マイ組織", "organization-management": "組織管理", "identity-provider-management": "IDプロバイダー管理", - "domain-management": "ドメイン管理" + "domain-management": "ドメイン管理", + "member-management": "メンバー" }, "mfa": { "title": "多要素認証" diff --git a/examples/react-spa-shadcn/src/pages/MemberManagement.tsx b/examples/react-spa-shadcn/src/pages/MemberManagement.tsx index bd89bdcc3..b2f7a365f 100644 --- a/examples/react-spa-shadcn/src/pages/MemberManagement.tsx +++ b/examples/react-spa-shadcn/src/pages/MemberManagement.tsx @@ -1,5 +1,3 @@ -// import { OrganizationMemberManagement } from '@auth0/universal-components-react/spa'; - const MemberManagement = () => { return (
@@ -13,9 +11,7 @@ const MemberManagement = () => { {' '} on how to add Member Management component.

-
- {/* */} -
+
); }; diff --git a/examples/scripts/utils/env-writer.mjs b/examples/scripts/utils/env-writer.mjs index 52eee8c88..ab8f132c3 100644 --- a/examples/scripts/utils/env-writer.mjs +++ b/examples/scripts/utils/env-writer.mjs @@ -73,7 +73,9 @@ const MYORG_SCOPES = [ "create:my_org:domains", "update:my_org:domains", "read:my_org:member_invitations", - "delete:my_org:member_invitations" + "delete:my_org:member_invitations", + "create:my_org:member_invitations", + "read:my_org:member_roles" ] // My Account API scopes diff --git a/examples/scripts/utils/resource-servers.mjs b/examples/scripts/utils/resource-servers.mjs index 95fe0c88d..1e93b6028 100644 --- a/examples/scripts/utils/resource-servers.mjs +++ b/examples/scripts/utils/resource-servers.mjs @@ -27,7 +27,9 @@ export const MYORG_API_SCOPES = [ "delete:my_org:identity_providers_provisioning", "read:my_org:configuration", "read:my_org:member_invitations", -"delete:my_org:member_invitations" +"delete:my_org:member_invitations", +"create:my_org:member_invitations", +"read:my_org:member_roles" ] // My Account API Scopes - desired scopes for MFA management diff --git a/packages/core/src/i18n/translations/en-US.json b/packages/core/src/i18n/translations/en-US.json index 62d83370a..c75512ed0 100644 --- a/packages/core/src/i18n/translations/en-US.json +++ b/packages/core/src/i18n/translations/en-US.json @@ -1125,6 +1125,7 @@ "menu_label": "Actions", "view_details": "View Details", "copy_url": "Copy Invitation URL", + "copied": "Copied!", "revoke_and_resend": "Revoke and Resend", "revoke": "Revoke Invitation" }, @@ -1146,7 +1147,8 @@ "submit_button": "Send Invite", "creating": "Creating...", "cancel_button": "Cancel", - "success": "Invitation sent to ${email}." + "success": "Invitation has been sent to ${email}.", + "success_bulk": "Invitations have been sent." }, "details": { "title": "Invitation Details", @@ -1178,7 +1180,7 @@ }, "success": { "url_copied": "Invitation URL copied to clipboard.", - "invitation_resent": "Invitation resent to ${email}." + "invitation_resent": "Previous invite revoked. A new invitation has been sent to ${email}." }, "error": { "fetch_failed": "Failed to load invitations. Please try again.", diff --git a/packages/core/src/i18n/translations/ja.json b/packages/core/src/i18n/translations/ja.json index a025fecb3..c5d1c417e 100644 --- a/packages/core/src/i18n/translations/ja.json +++ b/packages/core/src/i18n/translations/ja.json @@ -1127,6 +1127,7 @@ "menu_label": "アクション", "view_details": "詳細を表示", "copy_url": "招待URLをコピー", + "copied": "コピーしました!", "revoke_and_resend": "取り消して再送信", "revoke": "招待を取り消す" }, @@ -1148,7 +1149,8 @@ "submit_button": "招待を送信", "creating": "作成中...", "cancel_button": "キャンセル", - "success": "${email}に招待を送信しました。" + "success": "${email}に招待が送信されました。", + "success_bulk": "招待が送信されました。" }, "details": { "title": "招待の詳細", @@ -1180,7 +1182,7 @@ }, "success": { "url_copied": "招待URLをクリップボードにコピーしました。", - "invitation_resent": "${email}に招待を再送信しました。" + "invitation_resent": "以前の招待が取り消されました。${email}に新しい招待が送信されました。" }, "error": { "fetch_failed": "招待の読み込みに失敗しました。もう一度お試しください。", 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 2290e2308..042d29089 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 @@ -7,4 +7,5 @@ export const memberManagementQueryKeys = { all: ['member-management'] as const, invitations: () => [...memberManagementQueryKeys.all, 'invitations'] as const, + roles: () => [...memberManagementQueryKeys.all, 'roles'] 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 014bd21e2..b4b7055cd 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 @@ -87,3 +87,13 @@ export type CreateMemberInvitationResponseContent = * Response content for getting a member invitation. */ export type GetMemberInvitationResponseContent = MyOrganization.GetMemberInvitationResponseContent; + +/** + * Organization role available for binding to members and invitations. + */ +export type Role = MyOrganization.Role; + +/** + * Organization role ID. + */ +export type RoleId = MyOrganization.RoleId; 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 09bd4b032..6bd3ee917 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 @@ -42,7 +42,6 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage isRevokingInvitation, isResendingInvitation, invitationPagination, - invitationFilters, invitationSortConfig, availableRoles, availableProviders, @@ -122,8 +121,6 @@ export function OrganizationMemberManagementView(props: OrganizationMemberManage loading={isFetchingInvitations} customMessages={customMessages?.invitation} pagination={invitationPagination} - filters={invitationFilters} - availableRoles={availableRoles} readOnly={readOnly} sortConfig={invitationSortConfig} onSortChange={handleSortChange} @@ -223,7 +220,7 @@ export function OrganizationMemberManagement(props: OrganizationMemberManagement }); return ( - + { + const invitationUrl = 'https://example.auth0.com/invite?ticket=abc'; + afterEach(() => { vi.clearAllMocks(); }); @@ -172,7 +174,7 @@ describe('OrganizationInvitationDetailsModal', () => { describe('invitation URL', () => { it('should display invitation URL when available', () => { const invitation = createMockInvitation({ - invitation_url: 'https://example.auth0.com/invite?ticket=abc', + invitation_url: invitationUrl, }); renderWithProviders( @@ -266,6 +268,85 @@ describe('OrganizationInvitationDetailsModal', () => { }); }); + describe('copy URL', () => { + it('should call onCopyUrl and show copied state when copy button is clicked', async () => { + const user = userEvent.setup(); + const onCopyUrl = vi.fn(); + const invitation = createMockInvitation({ + invitation_url: invitationUrl, + }); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole('button', { name: 'invitation.details.copy_url_button' })); + + expect(onCopyUrl).toHaveBeenCalledTimes(1); + expect(onCopyUrl).toHaveBeenCalledWith(invitation); + expect(screen.getByRole('button', { name: 'invitation.details.copied' })).toBeInTheDocument(); + }); + + describe('with fake timers', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should reset copied state after 3 seconds', () => { + vi.useFakeTimers(); + + const invitation = createMockInvitation({ + invitation_url: invitationUrl, + }); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'invitation.details.copy_url_button' })); + + expect( + screen.getByRole('button', { name: 'invitation.details.copied' }), + ).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(3000)); + + expect( + screen.getByRole('button', { name: 'invitation.details.copy_url_button' }), + ).toBeInTheDocument(); + }); + + it('should clear the timeout when modal is closed before 3 seconds', () => { + vi.useFakeTimers(); + + const invitation = createMockInvitation({ + invitation_url: invitationUrl, + }); + + const { rerender } = renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'invitation.details.copy_url_button' })); + + rerender( + + + , + ); + + act(() => vi.advanceTimersByTime(3000)); + + // no state update errors — timeout was cleared cleanly + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + }); + describe('action callbacks', () => { it('should call onRevoke when Revoke button is clicked', async () => { const user = userEvent.setup(); 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 c0bcb91ad..7b51c51f7 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 @@ -3,10 +3,9 @@ * @module organization-invitation-details-modal */ -import { Link } from 'lucide-react'; +import { Link, Copy, Check } from 'lucide-react'; import * as React from 'react'; -import { CopyableTextField } from '@/components/auth0/shared/copyable-text-field'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -28,8 +27,6 @@ import type { OrganizationInvitationDetailsModalProps, } from '@/types/my-organization/member-management/organization-invitation-table-types'; -export type { OrganizationInvitationDetailsModalProps }; - /** * Returns the badge variant for a given invitation status. * @param status - The invitation status. @@ -94,9 +91,30 @@ export function OrganizationInvitationDetailsModal({ return provider?.name ?? invitation.identity_provider_id; }, [invitation?.identity_provider_id, availableProviders]); - const handleCopyUrl = React.useCallback(() => { + const [copied, setCopied] = React.useState(false); + const copyTimeoutRef = React.useRef | null>(null); + + React.useEffect(() => { + if (isOpen) setCopied(false); + cleanUpTimeout(); + return () => { + cleanUpTimeout(); + }; + }, [isOpen]); + + const cleanUpTimeout = () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + }; + + const handleCopyUrlClick = React.useCallback(() => { if (invitation) { onCopyUrl?.(invitation); + setCopied(true); + cleanUpTimeout(); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 3000); } }, [invitation, onCopyUrl]); @@ -185,11 +203,30 @@ export function OrganizationInvitationDetailsModal({ - } + endAdornment={ + + } /> )} diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx index 25a926278..e187145fb 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal.tsx @@ -18,8 +18,6 @@ import { Spinner } from '@/components/ui/spinner'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { OrganizationInvitationRevokeModalProps } from '@/types/my-organization/member-management/organization-invitation-table-types'; -export type { OrganizationInvitationRevokeModalProps }; - /** * Modal for confirming invitation revocation or revoke and resend. * @param props - The component props. diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column.tsx index dae2372b9..402bf504c 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table-actions-column.tsx @@ -15,6 +15,7 @@ import { DropdownMenuPortal, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useTranslator } from '@/hooks/shared/use-translator'; import { getInvitationStatus } from '@/lib/utils/my-organization/member-management/member-management-utils'; import type { OrganizationInvitationTableActionsColumnProps } from '@/types/my-organization/member-management/organization-invitation-table-types'; @@ -45,12 +46,16 @@ export function OrganizationInvitationTableActionsColumn({ const status = getInvitationStatus(invitation); const isPending = status === 'pending'; + const [copiedTooltipOpen, setCopiedTooltipOpen] = React.useState(false); + const handleViewDetails = React.useCallback(() => { onViewDetails?.(invitation); }, [invitation, onViewDetails]); const handleCopyUrl = React.useCallback(() => { onCopyUrl?.(invitation); + setCopiedTooltipOpen(true); + setTimeout(() => setCopiedTooltipOpen(false), 1500); }, [invitation, onCopyUrl]); const handleRevokeAndResend = React.useCallback(() => { @@ -63,48 +68,56 @@ export function OrganizationInvitationTableActionsColumn({ return (
- - - - - - - {/* View Details - always available */} - - - {t('invitation.actions.view_details')} - + + +
+ + + + + + + {/* View Details - always available */} + + + {t('invitation.actions.view_details')} + - {/* Copy URL - only for pending invitations with URL */} - {isPending && invitation.invitation_url && ( - - - {t('invitation.actions.copy_url')} - - )} + {isPending && invitation.invitation_url && ( + + + {t('invitation.actions.copy_url')} + + )} - {!readOnly && ( - <> - - - {t('invitation.actions.revoke_and_resend')} - - - - - {t('invitation.actions.revoke')} - - - )} - - - + {!readOnly && ( + <> + + + {t('invitation.actions.revoke_and_resend')} + + + + + {t('invitation.actions.revoke')} + + + )} + + + +
+
+ + {t('invitation.actions.copied')} + +
); } diff --git a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx index f68ba2ef0..4bbf78ccd 100644 --- a/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx +++ b/packages/react/src/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table.tsx @@ -79,15 +79,17 @@ export function OrganizationInvitationTable({ type: 'text', accessorKey: 'invitee', title: t('invitation.table.columns.email'), + width: '25%', enableSorting: false, render: (invitation) => ( -
{invitation.invitee?.email}
+
{invitation.invitee?.email}
), }, { type: 'text', accessorKey: 'organization_id', title: t('invitation.table.columns.status'), + width: '10%', enableSorting: false, render: (invitation) => { const status = getInvitationStatus(invitation); @@ -163,7 +165,7 @@ export function OrganizationInvitationTable({ onSortChange={onSortChange} /> - {invitations.length > 0 && ( + {!loading && invitations.length > 0 && (
(); const [emailError, setEmailError] = React.useState(); + const resetForm = React.useCallback(() => { + setEmailInput(''); + setEmailChips([]); + setSelectedRoles([]); + setSelectedProvider(undefined); + setEmailError(undefined); + }, []); + + React.useEffect(() => { + if (!isOpen) { + resetForm(); + } + }, [isOpen, resetForm]); + const handleEmailInputChange = React.useCallback((e: React.ChangeEvent) => { setEmailInput(e.target.value); setEmailError(undefined); @@ -136,55 +148,50 @@ export function OrganizationInvitationCreateModal({ setSelectedProvider(value || undefined); }, []); - const handleSubmit = React.useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - const finalEmails = emailChips - .filter((chip) => chip.variant !== 'destructive') - .map((chip) => chip.value); + const handleSubmit = React.useCallback(() => { + const finalEmails = emailChips + .filter((chip) => chip.variant !== 'destructive') + .map((chip) => chip.value); - if (emailInput.trim()) { - const trimmedEmail = emailInput.trim(); - const result = validationConfig.emailSchema.safeParse(trimmedEmail); - if (result.success && !finalEmails.includes(trimmedEmail)) { - finalEmails.push(trimmedEmail); - } - } - - if (finalEmails.length === 0) { - setEmailError(t('invitation.create.email_required_error')); + if (emailInput.trim()) { + const trimmedEmail = emailInput.trim(); + const result = validationConfig.emailSchema.safeParse(trimmedEmail); + if (result.success && !finalEmails.includes(trimmedEmail)) { + finalEmails.push(trimmedEmail); + } else if (!result.success) { + setEmailError(t('invitation.create.email_invalid_error')); return; } + } - onCreate({ - invitees: finalEmails.map((email) => ({ - email, - roles: selectedRoles.length > 0 ? selectedRoles : undefined, - })), - identity_provider_id: selectedProvider, - ...(inviterName && { inviter: { name: inviterName } }), - }); - }, - [ - emailChips, - emailInput, - validationConfig, - selectedRoles, - selectedProvider, - inviterName, - onCreate, - t, - ], - ); + if (finalEmails.length === 0) { + setEmailError(t('invitation.create.email_required_error')); + return; + } + + onCreate({ + invitees: finalEmails.map((email) => ({ + email, + roles: selectedRoles.length > 0 ? selectedRoles : undefined, + })), + identity_provider_id: selectedProvider, + ...(inviterName && { inviter: { name: inviterName } }), + }); + }, [ + emailChips, + emailInput, + validationConfig, + selectedRoles, + selectedProvider, + inviterName, + onCreate, + t, + ]); const handleClose = React.useCallback(() => { - setEmailInput(''); - setEmailChips([]); - setSelectedRoles([]); - setSelectedProvider(undefined); - setEmailError(undefined); + resetForm(); onClose(); - }, [onClose]); + }, [onClose, resetForm]); const canSubmit = React.useMemo( () => @@ -203,75 +210,74 @@ export function OrganizationInvitationCreateModal({ return ( -
- - {t('invitation.create.title')} - {t('invitation.create.description')} - + + {t('invitation.create.title')} + {t('invitation.create.description')} + -
- {/* Email Input */} -
- - -

{t('invitation.create.email_helper')}

- {emailError &&

{emailError}

} -
+
+
+ + +

{t('invitation.create.email_helper')}

+ {emailError &&

{emailError}

} +
- {/* Roles Combobox */} -
- - -
+
+ + +
- {/* Provider Dropdown */} -
- - -

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

-
+
+ + +

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

+
- - - - - + + + +
); diff --git a/packages/react/src/components/ui/combobox.tsx b/packages/react/src/components/ui/combobox.tsx index 9296eb42c..d72b06c2d 100644 --- a/packages/react/src/components/ui/combobox.tsx +++ b/packages/react/src/components/ui/combobox.tsx @@ -402,7 +402,7 @@ export function Combobox({ diff --git a/packages/react/src/components/ui/select.tsx b/packages/react/src/components/ui/select.tsx index 3e2e386e5..e5f978d04 100644 --- a/packages/react/src/components/ui/select.tsx +++ b/packages/react/src/components/ui/select.tsx @@ -63,7 +63,7 @@ function SelectContent({ { ).toHaveBeenCalled(); }); - it('should not fetch identity providers when members tab is active', () => { + it('should not fetch identity providers when members tab is active', async () => { const options = createDefaultOptions({ activeTab: 'members' }); const { result } = renderService(options); + // Wait for rolesQuery to settle (it's always enabled) + await waitFor(() => { + expect(result.current.rolesQuery.isSuccess).toBe(true); + }); + expect(result.current.providersQuery.fetchStatus).toBe('idle'); }); }); + describe('rolesQuery', () => { + it('should fetch roles when coreClient is available', async () => { + const options = createDefaultOptions(); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.rolesQuery.isSuccess).toBe(true); + }); + + expect(result.current.rolesQuery.data).toBeDefined(); + }); + + it('should return roles data', async () => { + const options = createDefaultOptions(); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.rolesQuery.isSuccess).toBe(true); + }); + + expect(result.current.rolesQuery.data).toEqual([ + { id: 'rol_admin', name: 'admin', description: 'Admin role' }, + ]); + }); + + it('should fetch roles regardless of active tab', async () => { + const options = createDefaultOptions({ activeTab: 'members' }); + const { result } = renderService(options); + + await waitFor(() => { + expect(result.current.rolesQuery.isSuccess).toBe(true); + }); + + expect(result.current.rolesQuery.data).toBeDefined(); + }); + }); + describe('invitationsQuery', () => { it('should fetch invitations when invitations tab is active', async () => { const options = createDefaultOptions({ activeTab: 'invitations' }); @@ -168,7 +210,7 @@ describe('useMemberManagementService', () => { .fn() .mockResolvedValue({ data: [mockInvitation], - response: { next: 'next_token', total: 5 }, + response: { next: 'next_token' }, }); const options = createDefaultOptions(); @@ -181,7 +223,6 @@ describe('useMemberManagementService', () => { expect(result.current.invitationsQuery.data).toEqual({ invitations: [mockInvitation], next: 'next_token', - total: 5, }); }); }); 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 a8419c953..f6ac37a94 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 @@ -9,7 +9,7 @@ import { type ListIdentityProvidersResponseContent, memberManagementQueryKeys, } from '@auth0/universal-components-core'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import * as React from 'react'; import { showToast } from '@/components/auth0/shared/toast'; @@ -79,7 +79,18 @@ export function useMemberManagementService( type: p.strategy, })); }, - enabled: !!coreClient && isInvitationsTabActive, + enabled: !!coreClient, + }); + + const rolesQuery = useQuery({ + queryKey: memberManagementQueryKeys.roles(), + queryFn: async () => { + const response = await coreClient! + .getMyOrganizationApiClient() + .organization.roles.list({ take: 50 }); + return response.data; + }, + enabled: !!coreClient, }); const invitationsQuery = useQuery({ @@ -99,11 +110,11 @@ export function useMemberManagementService( const invitations: MemberInvitation[] = page.data; const next = page.response.next ?? null; - const total = (page.response as Record).total as number | undefined; - return { invitations, next, total }; + return { invitations, next }; }, enabled: !!coreClient && isInvitationsTabActive, + placeholderData: keepPreviousData, }); const createInvitationMutation = useMutation({ @@ -116,13 +127,18 @@ export function useMemberManagementService( .organization.invitations.create({ invitees: data.invitees, inviter: data.inviter, + identity_provider_id: data.identity_provider_id, ttl_sec: data.ttl_sec, }); return Array.isArray(response) ? response[0] : response; }, onSuccess: (result, data) => { createInvitationAction?.onAfter?.(data, result); - showToast({ type: 'success', message: t('invitation.create.success') }); + const isBulk = data.invitees.length > 1; + const message = isBulk + ? t('invitation.create.success_bulk') + : t('invitation.create.success', { email: data.invitees[0]?.email ?? '' }); + showToast({ type: 'success', message }); queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); }, onError: (error) => { @@ -142,7 +158,10 @@ export function useMemberManagementService( }, onSuccess: (invitation) => { revokeInvitationAction?.onAfter?.(invitation); - showToast({ type: 'success', message: t('invitation.revoke.success') }); + showToast({ + type: 'success', + message: t('invitation.revoke.success', { email: invitation.invitee?.email ?? '' }), + }); queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); }, onError: (error) => { @@ -172,7 +191,12 @@ export function useMemberManagementService( }, onSuccess: (result, invitation) => { resendInvitationAction?.onAfter?.(invitation, result); - showToast({ type: 'success', message: t('invitation.success.invitation_resent') }); + showToast({ + type: 'success', + message: t('invitation.success.invitation_resent', { + email: invitation.invitee?.email ?? '', + }), + }); queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); }, onError: (error) => { @@ -190,6 +214,7 @@ export function useMemberManagementService( return { providersQuery, + rolesQuery, invitationsQuery, createInvitationMutation, revokeInvitationMutation, diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index bf0488256..f3db4455f 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -51,11 +51,6 @@ export function useConfig(): UseConfigResult { const isConfigValid = !!allowedStrategies?.length; - const allowedRoles = - ((config as Record)?.allowed_roles as - | Array<{ id: string; name: string; description?: string }> - | undefined) ?? []; - return { config: config ?? null, isLoadingConfig: configQuery.isLoading, @@ -63,6 +58,5 @@ export function useConfig(): UseConfigResult { filteredStrategies, shouldAllowDeletion, isConfigValid, - allowedRoles, }; } 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 01b62b429..53c4f6bcf 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 @@ -8,14 +8,12 @@ import * as React from 'react'; import { showToast } from '@/components/auth0/shared/toast'; import { useMemberManagementService } from '@/hooks/my-organization/shared/services/use-member-management-service'; -import { useConfig } from '@/hooks/my-organization/use-config'; import { useCheckpointPagination } from '@/hooks/shared/use-checkpoint-pagination'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { CreateInvitationInput, InvitationFilterState, InvitationSortConfig, - RoleOption, IdentityProviderOption, } from '@/types/my-organization/member-management/organization-invitation-table-types'; import type { @@ -45,9 +43,6 @@ export function useOrganizationMemberManagement( const [activeTab, setActiveTab] = React.useState('members'); - const { allowedRoles } = useConfig(); - const availableRoles: RoleOption[] = allowedRoles; - const { pageSize: invitationPageSize, currentPage: invitationCurrentPage, @@ -67,6 +62,7 @@ export function useOrganizationMemberManagement( const { providersQuery, + rolesQuery, invitationsQuery, createInvitationMutation, revokeInvitationMutation, @@ -87,9 +83,9 @@ export function useOrganizationMemberManagement( }); const availableProviders: IdentityProviderOption[] = providersQuery.data ?? []; + const availableRoles = rolesQuery.data ?? []; const currentInvitations = invitationsQuery.data?.invitations ?? []; const invitationNextToken = invitationsQuery.data?.next ?? null; - const invitationsTotalItems = invitationsQuery.data?.total; const openModal = React.useCallback( async (state: MemberManagementModalState) => { @@ -141,18 +137,10 @@ export function useOrganizationMemberManagement( }); }, [modalState, resendInvitationMutation, closeModal]); - const handleCopyUrl = React.useCallback( - async (invitation: MemberInvitation) => { - if (!invitation.invitation_url) return; - try { - await navigator.clipboard.writeText(invitation.invitation_url); - showToast({ type: 'success', message: t('invitation.success.url_copied') }); - } catch { - showToast({ type: 'error', message: t('invitation.error.copy_url_failed') }); - } - }, - [t], - ); + const handleCopyUrl = React.useCallback(async (invitation: MemberInvitation) => { + if (!invitation.invitation_url) return; + await navigator.clipboard.writeText(invitation.invitation_url); + }, []); const handleNextPage = React.useCallback(() => { if (invitationNextToken) { @@ -191,14 +179,14 @@ export function useOrganizationMemberManagement( availableProviders, invitations: currentInvitations, - isFetchingInvitations: invitationsQuery.isLoading || invitationsQuery.isFetching, + isInitialLoading: invitationsQuery.isLoading, + isFetchingInvitations: invitationsQuery.isFetching, isCreatingInvitation: createInvitationMutation.isPending, isRevokingInvitation: revokeInvitationMutation.isPending, isResendingInvitation: resendInvitationMutation.isPending, invitationPagination: { pageSize: invitationPageSize, currentPage: invitationCurrentPage, - totalItems: invitationsTotalItems, hasNextPage: !!invitationNextToken, hasPreviousPage: invitationHasPreviousPage, }, diff --git a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts index 2b0f13b39..dd682ce97 100644 --- a/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/internals/__mocks__/my-organization/config/config.mocks.ts @@ -20,6 +20,5 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], - allowedRoles: [], ...overrides, }); 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 02aa5053c..f7d3a84d7 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 @@ -65,6 +65,12 @@ const createMockMyOrgApiService = (): CoreClientInterface['myOrganizationApiClie create: vi.fn().mockResolvedValue([createMockInvitation()]), delete: vi.fn().mockResolvedValue(undefined), }, + roles: { + list: vi.fn().mockResolvedValue({ + data: [{ id: 'rol_admin', name: 'admin', description: 'Admin role' }], + response: { next: null }, + }), + }, domains: { list: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue({}), diff --git a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts index 9a78bfa91..102991ff4 100644 --- a/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts +++ b/packages/react/src/tests/utils/__mocks__/my-organization/config/config.mocks.ts @@ -14,6 +14,5 @@ export const createMockUseConfig = (overrides?: Partial): MockUse }, fetchConfig: vi.fn(async () => undefined), filteredStrategies: [], - allowedRoles: [], ...overrides, }); 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 79ec5d15d..1dbc318ec 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,12 +1,11 @@ -import type { MemberInvitation } from '@auth0/universal-components-core'; +import type { MemberInvitation, Role } from '@auth0/universal-components-core'; import { vi } from 'vitest'; -import type { OrganizationInvitationDetailsModalProps } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal'; -import type { OrganizationInvitationRevokeModalProps } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal'; -import type { OrganizationInvitationCreateModalProps } from '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal'; import type { - RoleOption, IdentityProviderOption, + OrganizationInvitationCreateModalProps, + OrganizationInvitationDetailsModalProps, + OrganizationInvitationRevokeModalProps, OrganizationInvitationTableActionsColumnProps, SearchFilterProps, } from '@/types/my-organization/member-management/organization-invitation-table-types'; @@ -39,7 +38,7 @@ export const createMockExpiredInvitation = ( ...overrides, }); -export const createMockRoles = (): RoleOption[] => [ +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' }, diff --git a/packages/react/src/types/my-organization/config/config-types.ts b/packages/react/src/types/my-organization/config/config-types.ts index 3b7a27cba..b305c8cd9 100644 --- a/packages/react/src/types/my-organization/config/config-types.ts +++ b/packages/react/src/types/my-organization/config/config-types.ts @@ -8,13 +8,6 @@ import type { IdpStrategy, } from '@auth0/universal-components-core'; -/** Role returned from organization configuration. */ -export interface ConfigRole { - id: string; - name: string; - description?: string; -} - /** useConfig hook result. */ export interface UseConfigResult { config: GetConfigurationResponseContent | null; @@ -23,5 +16,4 @@ export interface UseConfigResult { filteredStrategies: IdpStrategy[]; shouldAllowDeletion: boolean; isConfigValid: boolean; - allowedRoles: ConfigRole[]; } 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 103e91c8c..f79e1bccf 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 @@ -9,18 +9,12 @@ import type { MemberInvitation, InvitationCreateSchemas, OrganizationInvitationTabMessages, + Role, } from '@auth0/universal-components-core'; /** Invitation status. */ export type InvitationStatus = 'pending' | 'expired'; -/** Role option for invitation. */ -export interface RoleOption { - id: string; - name: string; - description?: string; -} - /** Identity provider option for invitation. */ export interface IdentityProviderOption { id: string; @@ -105,7 +99,7 @@ export interface OrganizationInvitationTableProps { pagination: InvitationPaginationState; filters?: InvitationFilterState; sortConfig?: InvitationSortConfig; - availableRoles?: RoleOption[]; + availableRoles?: Role[]; readOnly?: boolean; onView?: (invitation: MemberInvitation) => void; onCopyUrl?: (invitation: MemberInvitation) => void; @@ -122,7 +116,7 @@ export interface OrganizationInvitationTableProps { /** Props for SearchFilter component. */ export interface SearchFilterProps { filters?: InvitationFilterState; - availableRoles?: RoleOption[]; + availableRoles?: Role[]; customMessages?: Partial; className?: string; onRoleFilterChange?: (roleId: string | undefined) => void; @@ -133,7 +127,7 @@ export interface OrganizationInvitationCreateModalProps { isOpen: boolean; isLoading?: boolean; customMessages?: Partial; - availableRoles?: RoleOption[]; + availableRoles?: Role[]; availableProviders?: IdentityProviderOption[]; inviterName?: string; schema?: InvitationCreateSchemas; @@ -149,7 +143,7 @@ export interface OrganizationInvitationDetailsModalProps { isRevoking?: boolean; isResending?: boolean; customMessages?: Partial; - availableRoles?: RoleOption[]; + availableRoles?: Role[]; availableProviders?: IdentityProviderOption[]; readOnly?: boolean; 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 7acd0589b..c39ca3889 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 @@ -8,6 +8,7 @@ import type { SharedComponentProps, MemberInvitation, OrganizationMemberManagementMessages, + Role, } from '@auth0/universal-components-core'; import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; @@ -18,7 +19,6 @@ import type { InvitationPaginationState, InvitationSortConfig, OrganizationInvitationTabClasses, - RoleOption, } from './organization-invitation-table-types'; export type ActiveTab = 'members' | 'invitations'; @@ -41,10 +41,10 @@ export interface UseMemberManagementServiceOptions { export interface MemberManagementServiceResult { providersQuery: UseQueryResult; + rolesQuery: UseQueryResult; invitationsQuery: UseQueryResult<{ invitations: MemberInvitation[]; next: string | null; - total: number | undefined; }>; createInvitationMutation: UseMutationResult< MemberInvitation | undefined, @@ -81,10 +81,11 @@ export type MemberManagementModalState = export interface UseOrganizationMemberManagementResult { activeTab: ActiveTab; - availableRoles: RoleOption[]; + availableRoles: Role[]; availableProviders: IdentityProviderOption[]; invitations: MemberInvitation[]; + isInitialLoading: boolean; isFetchingInvitations: boolean; isCreatingInvitation: boolean; isRevokingInvitation: boolean;