diff --git a/examples/next-rwa/src/app/member-management/page.tsx b/examples/next-rwa/src/app/member-management/page.tsx new file mode 100644 index 000000000..9e354e616 --- /dev/null +++ b/examples/next-rwa/src/app/member-management/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { OrganizationMemberManagement } from '@auth0/universal-components-react/rwa'; + +export default function MemberManagementPage() { + return ( +
+

+ Follow{' '} + + Quickstart guidance + {' '} + 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 e9a45a6f1..0eb2407d3 100644 --- a/examples/next-rwa/src/components/navigation/side-bar.tsx +++ b/examples/next-rwa/src/components/navigation/side-bar.tsx @@ -1,7 +1,7 @@ 'use client'; import { useUser } from '@auth0/nextjs-auth0'; -import { Building, Settings, Shield, User } from 'lucide-react'; +import { Building, Settings, Shield, User, Users } from 'lucide-react'; import Link from 'next/link'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -74,6 +74,15 @@ export const Sidebar: React.FC = () => { {t('sidebar.identity-providers')} +
  • + + + {t('sidebar.members')} + +
  • diff --git a/examples/next-rwa/src/providers/i18n-provider.tsx b/examples/next-rwa/src/providers/i18n-provider.tsx index 793c0e42d..5312cc407 100644 --- a/examples/next-rwa/src/providers/i18n-provider.tsx +++ b/examples/next-rwa/src/providers/i18n-provider.tsx @@ -26,6 +26,7 @@ i18n.use(initReactI18next).init({ 'sidebar.organization-settings': 'Organization Settings', 'sidebar.domains': 'Domains', 'sidebar.identity-providers': 'Identity Providers', + 'sidebar.members': 'Members', }, }, }, diff --git a/examples/react-spa-npm/src/App.tsx b/examples/react-spa-npm/src/App.tsx index 08ec086e1..9b443b784 100644 --- a/examples/react-spa-npm/src/App.tsx +++ b/examples/react-spa-npm/src/App.tsx @@ -8,6 +8,7 @@ import { Navbar } from './components/nav-bar'; import { Sidebar } from './components/side-bar'; import DomainManagementPage from './views/domain-management-page'; import HomePage from './views/home-page'; +import MemberManagementPage from './views/member-management-page'; import MFAPage from './views/mfa-page'; import OrganizationManagementPage from './views/organization-management-page'; import ProfilePage from './views/profile-page'; @@ -101,6 +102,14 @@ function AppContent() { } /> + + + + } + /> diff --git a/examples/react-spa-npm/src/components/side-bar.tsx b/examples/react-spa-npm/src/components/side-bar.tsx index 402398c19..58ab2150e 100644 --- a/examples/react-spa-npm/src/components/side-bar.tsx +++ b/examples/react-spa-npm/src/components/side-bar.tsx @@ -1,4 +1,4 @@ -import { User, Building, Settings, Shield } from 'lucide-react'; +import { User, Building, Settings, Shield, Users } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -70,6 +70,15 @@ export const Sidebar: React.FC = () => { {t('sidebar.domain-management')} +
  • + + + {t('sidebar.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 new file mode 100644 index 000000000..e9667f79d --- /dev/null +++ b/examples/react-spa-npm/src/views/member-management-page.tsx @@ -0,0 +1,21 @@ +// import { OrganizationMemberManagement } from '@auth0/universal-components-react/spa'; + +const MemberManagementPage = () => { + return ( +
    +

    + Follow{' '} + + Quickstart guidance + {' '} + on how to add Member Management component. +

    + {/* */} +
    + ); +}; + +export default MemberManagementPage; diff --git a/examples/react-spa-shadcn/src/App.tsx b/examples/react-spa-shadcn/src/App.tsx index c39fdede8..f8f142aa2 100644 --- a/examples/react-spa-shadcn/src/App.tsx +++ b/examples/react-spa-shadcn/src/App.tsx @@ -10,6 +10,7 @@ import { Sidebar } from './components/side-bar'; import { config } from './config/env'; // import { useDarkMode } from './hooks/use-dark-mode'; import DomainManagement from './pages/DomainManagement'; +import MemberManagement from './pages/MemberManagement'; import IdentityProviderManagement from './pages/IdentityProviderManagement'; import IdentityProviderManagementCreate from './pages/IdentityProviderManagementCreate'; import IdentityProviderManagementEdit from './pages/IdentityProviderManagementEdit'; @@ -136,6 +137,14 @@ const App = () => { } /> + + + + } + /> {/* */} diff --git a/examples/react-spa-shadcn/src/components/side-bar.tsx b/examples/react-spa-shadcn/src/components/side-bar.tsx index e0bc08a9b..591c48d73 100644 --- a/examples/react-spa-shadcn/src/components/side-bar.tsx +++ b/examples/react-spa-shadcn/src/components/side-bar.tsx @@ -1,4 +1,4 @@ -import { User, Building, Shield, Settings } from 'lucide-react'; +import { User, Building, Shield, Settings, Users } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -70,6 +70,15 @@ export const Sidebar: React.FC = () => { {t('sidebar.domain-management')} +
  • + + + {t('sidebar.member-management')} + +
  • diff --git a/examples/react-spa-shadcn/src/pages/MemberManagement.tsx b/examples/react-spa-shadcn/src/pages/MemberManagement.tsx new file mode 100644 index 000000000..bd89bdcc3 --- /dev/null +++ b/examples/react-spa-shadcn/src/pages/MemberManagement.tsx @@ -0,0 +1,23 @@ +// import { OrganizationMemberManagement } from '@auth0/universal-components-react/spa'; + +const MemberManagement = () => { + return ( +
    +

    + Follow{' '} + + Quickstart guidance + {' '} + on how to add Member Management component. +

    +
    + {/* */} +
    +
    + ); +}; + +export default MemberManagement; diff --git a/examples/scripts/utils/env-writer.mjs b/examples/scripts/utils/env-writer.mjs index 9b4d573fd..52eee8c88 100644 --- a/examples/scripts/utils/env-writer.mjs +++ b/examples/scripts/utils/env-writer.mjs @@ -72,6 +72,8 @@ const MYORG_SCOPES = [ "delete:my_org:domains", "create:my_org:domains", "update:my_org:domains", + "read:my_org:member_invitations", + "delete:my_org:member_invitations" ] // My Account API scopes diff --git a/examples/scripts/utils/resource-servers.mjs b/examples/scripts/utils/resource-servers.mjs index d7eeb6b91..95fe0c88d 100644 --- a/examples/scripts/utils/resource-servers.mjs +++ b/examples/scripts/utils/resource-servers.mjs @@ -26,6 +26,8 @@ export const MYORG_API_SCOPES = [ "read:my_org:identity_providers_provisioning", "delete:my_org:identity_providers_provisioning", "read:my_org:configuration", +"read:my_org:member_invitations", +"delete:my_org:member_invitations" ] // My Account API Scopes - desired scopes for MFA management 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 new file mode 100644 index 000000000..c5bf919cd --- /dev/null +++ b/packages/react/src/components/auth0/my-organization/organization-member-management.tsx @@ -0,0 +1,247 @@ +/** + * Organization member management component. + * @module organization-member-management + */ + +import { getComponentStyles } from '@auth0/universal-components-core'; +import { Plus } from 'lucide-react'; +import * as React from 'react'; + +import { GateKeeper } from '../shared/gate-keeper/gate-keeper'; + +import { OrganizationInvitationDetailsModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-details/organization-invitation-details-modal'; +import { OrganizationInvitationRevokeModal } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-revoke/organization-invitation-revoke-modal'; +import { OrganizationInvitationTable } from '@/components/auth0/my-organization/shared/member-management/invitations/invitation-table/organization-invitation-table'; +import { OrganizationInvitationCreateModal } from '@/components/auth0/my-organization/shared/member-management/shared/invitation-create/organization-invitation-create-modal'; +import { Header } from '@/components/auth0/shared/header'; +import { StyledScope } from '@/components/auth0/shared/styled-scope'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useOrganizationMemberManagement } from '@/hooks/my-organization/use-organization-member-management'; +import { useTheme } from '@/hooks/shared/use-theme'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { + OrganizationMemberManagementProps, + UseOrganizationMemberManagementResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +/** + * Props for the OrganizationMemberManagementView component. + */ +export interface OrganizationMemberManagementViewProps + extends UseOrganizationMemberManagementResult { + styling: OrganizationMemberManagementProps['styling']; + customMessages: OrganizationMemberManagementProps['customMessages']; + hideHeader: boolean; + readOnly: boolean; +} + +/** + * View component for organization member management. + * @param props - The component props. + * @returns The component. + */ +export function OrganizationMemberManagementView(props: OrganizationMemberManagementViewProps) { + const { + styling, + customMessages, + hideHeader, + readOnly, + activeTab, + invitations, + isFetchingInvitations, + isCreatingInvitation, + isRevokingInvitation, + isResendingInvitation, + invitationPagination, + invitationFilters, + invitationSortConfig, + availableRoles, + availableProviders, + modalState, + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleCopyUrl, + handleSortChange, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleRoleFilterChange, + } = props; + + const selectedInvitation = + modalState.type === 'details' || + modalState.type === 'revoke' || + modalState.type === 'revokeResend' + ? modalState.invitation + : null; + + const { isDarkMode } = useTheme(); + const { t } = useTranslator('member_management', customMessages as Record); + + const currentStyles = React.useMemo( + () => getComponentStyles(styling, isDarkMode), + [styling, isDarkMode], + ); + + return ( + +
    + {!hideHeader && ( +
    +
    openModal({ type: 'create' }), + icon: Plus, + disabled: readOnly, + }, + ] + : [] + } + /> +
    + )} + + setActiveTab(value as 'members' | 'invitations')} + className={currentStyles.classes?.['OrganizationMemberManagement-tabs']} + > + + {t('tabs.members')} + {t('tabs.invitations')} + + + + {/* */} + + + + openModal({ type: 'details', invitation })} + onCopyUrl={handleCopyUrl} + onRevokeAndResend={ + readOnly + ? undefined + : (invitation) => openModal({ type: 'revokeResend', invitation }) + } + onRevoke={ + readOnly ? undefined : (invitation) => openModal({ type: 'revoke', invitation }) + } + onNextPage={handleNextPage} + onPreviousPage={handlePreviousPage} + onPageSizeChange={handlePageSizeChange} + onRoleFilterChange={handleRoleFilterChange} + className={currentStyles.classes?.['OrganizationInvitationTab-table']} + /> + + + + + + invitation && openModal({ type: 'revoke', invitation })} + onResend={(invitation) => invitation && openModal({ type: 'revokeResend', invitation })} + className={currentStyles.classes?.['OrganizationInvitationTab-detailsModal']} + /> + + handleRevokeConfirm()} + className={currentStyles.classes?.['OrganizationInvitationTab-revokeModal']} + /> + + handleRevokeResendConfirm()} + className={currentStyles.classes?.['OrganizationInvitationTab-revokeResendModal']} + /> +
    +
    + ); +} + +/** + * Container component for organization member management. + * @param props - The component props. + * @returns The component. + */ +export function OrganizationMemberManagement(props: OrganizationMemberManagementProps) { + const { + hideHeader = false, + customMessages = {}, + styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, + readOnly = false, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + } = props; + + const memberManagement = useOrganizationMemberManagement({ + customMessages, + readOnly, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + }); + + return ( + + + + ); +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 9b0ccceee..e57cc966d 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -12,6 +12,7 @@ export { } from './auth0/my-organization/sso-provider-create'; export { SsoProviderTable, SsoProviderTableView } from './auth0/my-organization/sso-provider-table'; export { DomainTable, DomainTableView } from './auth0/my-organization/domain-table'; +export { OrganizationMemberManagement } from './auth0/my-organization/organization-member-management'; export { OrganizationDetailsEdit, OrganizationDetailsEditView, diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 646b4e008..f32bcfe11 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -28,3 +28,6 @@ export { useSsoDomainTab } from './my-organization/use-sso-domain-tab'; export { useSsoProviderCreate } from './my-organization/use-sso-provider-create'; export { useSsoProviderEdit } from './my-organization/use-sso-provider-edit'; export { useSsoProviderTable } from './my-organization/use-sso-provider-table'; + +// Member Management hooks +export { useOrganizationMemberManagement } from './my-organization/use-organization-member-management'; 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 new file mode 100644 index 000000000..f3a31f925 --- /dev/null +++ b/packages/react/src/hooks/my-organization/shared/services/use-member-management-service.ts @@ -0,0 +1,201 @@ +/** + * Member management service hook. + * @module use-member-management-service + * @internal + */ + +import { + type MemberInvitation, + type ListIdentityProvidersResponseContent, +} from '@auth0/universal-components-core'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; + +import { showToast } from '@/components/auth0/shared/toast'; +import { useCoreClient } from '@/hooks/shared/use-core-client'; +import { useTranslator } from '@/hooks/shared/use-translator'; +import type { + CreateInvitationInput, + InvitationSortConfig, +} from '@/types/my-organization/member-management/organization-invitation-table-types'; +import type { + UseMemberManagementServiceOptions, + MemberManagementServiceResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +export const memberManagementQueryKeys = { + all: ['member-management'] as const, + invitations: () => [...memberManagementQueryKeys.all, 'invitations'] as const, +}; + +const INVITATION_SORT_FIELD_MAP: Record = { + created_at: 'created_at', +}; + +/** + * Builds a sort parameter string for the API. + * @param sortConfig - The sort configuration. + * @returns The formatted sort string, or undefined if no valid sort key. + */ +function buildSortParam(sortConfig: InvitationSortConfig): string | undefined { + if (!sortConfig.key) return undefined; + const apiField = INVITATION_SORT_FIELD_MAP[sortConfig.key]; + if (!apiField) return undefined; + const direction = sortConfig.direction === 'asc' ? '1' : '-1'; + return `${apiField}:${direction}`; +} + +/** + * Service hook for member management API operations. + * @param options - Service configuration options. + * @returns Query and mutation objects for member management. + */ +export function useMemberManagementService( + options: UseMemberManagementServiceOptions, +): MemberManagementServiceResult { + const { + customMessages = {}, + activeTab, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + invitationParams, + } = options; + + const isInvitationsTabActive = activeTab === 'invitations'; + + const { coreClient } = useCoreClient(); + const { t } = useTranslator('member_management', customMessages as Record); + const queryClient = useQueryClient(); + + const providersQuery = useQuery({ + queryKey: [...memberManagementQueryKeys.all, 'identity-providers'], + 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, + })); + }, + enabled: !!coreClient && isInvitationsTabActive, + }); + + const invitationsQuery = useQuery({ + queryKey: [ + ...memberManagementQueryKeys.invitations(), + invitationParams.pageSize, + invitationParams.fromToken, + invitationParams.filters, + invitationParams.sortConfig, + ], + queryFn: async () => { + const page = await coreClient!.getMyOrganizationApiClient().organization.invitations.list({ + take: invitationParams.pageSize, + from: invitationParams.fromToken, + sort: buildSortParam(invitationParams.sortConfig), + }); + + 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 }; + }, + enabled: !!coreClient && isInvitationsTabActive, + }); + + const createInvitationMutation = useMutation({ + mutationFn: async (data: CreateInvitationInput) => { + if (createInvitationAction?.onBefore && !createInvitationAction.onBefore(data)) { + throw new Error('Create action cancelled by onBefore'); + } + const response = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.create({ + invitees: data.invitees, + inviter: data.inviter, + 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') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.create_failed') }); + }, + }); + + const revokeInvitationMutation = useMutation({ + mutationFn: async (invitation: MemberInvitation) => { + if (revokeInvitationAction?.onBefore && !revokeInvitationAction.onBefore(invitation)) { + throw new Error('Revoke action cancelled by onBefore'); + } + await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.delete(invitation.id!); + return invitation; + }, + onSuccess: (invitation) => { + revokeInvitationAction?.onAfter?.(invitation); + showToast({ type: 'success', message: t('invitation.revoke.success') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.revoke_failed') }); + }, + }); + + const resendInvitationMutation = useMutation({ + mutationFn: async (invitation: MemberInvitation) => { + if (resendInvitationAction?.onBefore && !resendInvitationAction.onBefore(invitation)) { + throw new Error('Resend action cancelled by onBefore'); + } + const freshInvitation = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.get(invitation.id!); + await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.delete(freshInvitation.id ?? invitation.id!); + const email = freshInvitation.invitee?.email ?? invitation.invitee?.email ?? ''; + const roles = freshInvitation.roles ?? invitation.roles; + const response = await coreClient! + .getMyOrganizationApiClient() + .organization.invitations.create({ + invitees: [{ email, roles }], + }); + return Array.isArray(response) ? response[0] : response; + }, + onSuccess: (result, invitation) => { + resendInvitationAction?.onAfter?.(invitation, result); + showToast({ type: 'success', message: t('invitation.success.invitation_resent') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + onError: () => { + showToast({ type: 'error', message: t('invitation.error.resend_failed') }); + queryClient.invalidateQueries({ queryKey: memberManagementQueryKeys.invitations() }); + }, + }); + + const fetchInvitationDetails = React.useCallback( + async (invitationId: string): Promise => { + return coreClient!.getMyOrganizationApiClient().organization.invitations.get(invitationId); + }, + [coreClient], + ); + + return { + providersQuery, + invitationsQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + fetchInvitationDetails, + }; +} diff --git a/packages/react/src/hooks/my-organization/use-config.ts b/packages/react/src/hooks/my-organization/use-config.ts index 01c57aa0e..bf0488256 100644 --- a/packages/react/src/hooks/my-organization/use-config.ts +++ b/packages/react/src/hooks/my-organization/use-config.ts @@ -51,6 +51,11 @@ 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, @@ -58,6 +63,6 @@ export function useConfig(): UseConfigResult { filteredStrategies, shouldAllowDeletion, isConfigValid, - allowedRoles: [], + 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 new file mode 100644 index 000000000..e7c5b232e --- /dev/null +++ b/packages/react/src/hooks/my-organization/use-organization-member-management.ts @@ -0,0 +1,225 @@ +/** + * Organization member management hook. + * @module use-organization-member-management + */ + +import { type MemberInvitation } from '@auth0/universal-components-core'; +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 { + ActiveTab, + MemberManagementModalState, + UseOrganizationMemberManagementOptions, + UseOrganizationMemberManagementResult, +} from '@/types/my-organization/member-management/organization-member-management-types'; + +export { memberManagementQueryKeys } from '@/hooks/my-organization/shared/services/use-member-management-service'; + +/** + * Hook for organization member management. + * @param options - Hook configuration options. + * @returns State and handler functions. + */ +export function useOrganizationMemberManagement( + options: UseOrganizationMemberManagementOptions, +): UseOrganizationMemberManagementResult { + const { + customMessages = {}, + readOnly = false, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + } = options; + + const { t } = useTranslator('member_management', customMessages as Record); + + const [activeTab, setActiveTab] = React.useState('members'); + + const { allowedRoles } = useConfig(); + const availableRoles: RoleOption[] = allowedRoles; + + const { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + fromToken: invitationFromToken, + hasPreviousPage: invitationHasPreviousPage, + sortConfig: invitationSortConfig, + filters: invitationFilters, + goToNextPage: invitationGoToNextPage, + goToPreviousPage: invitationGoToPreviousPage, + changePageSize: invitationChangePageSize, + changeSortConfig: invitationChangeSortConfig, + changeFilters: invitationChangeFilters, + } = useCheckpointPagination(); + + const [modalState, setModalState] = React.useState({ type: null }); + const detailsRequestIdRef = React.useRef(0); + + const { + providersQuery, + invitationsQuery, + createInvitationMutation, + revokeInvitationMutation, + resendInvitationMutation, + fetchInvitationDetails, + } = useMemberManagementService({ + customMessages, + activeTab, + createInvitationAction, + revokeInvitationAction, + resendInvitationAction, + invitationParams: { + pageSize: invitationPageSize, + fromToken: invitationFromToken, + sortConfig: invitationSortConfig, + filters: invitationFilters, + }, + }); + + const availableProviders: IdentityProviderOption[] = providersQuery.data ?? []; + const currentInvitations = invitationsQuery.data?.invitations ?? []; + const invitationNextToken = invitationsQuery.data?.next ?? null; + const invitationsTotalItems = invitationsQuery.data?.total; + + const openModal = React.useCallback( + async (state: MemberManagementModalState) => { + if (state.type === 'create' && readOnly) return; + if ((state.type === 'revoke' || state.type === 'revokeResend') && readOnly) return; + setModalState(state); + + if (state.type === 'details') { + const requestId = ++detailsRequestIdRef.current; + try { + const response = await fetchInvitationDetails(state.invitation.id!); + if (detailsRequestIdRef.current === requestId) { + setModalState({ type: 'details', invitation: response }); + } + } catch { + if (detailsRequestIdRef.current === requestId) { + showToast({ type: 'error', message: t('invitation.error.fetch_failed') }); + } + } + } + }, + [readOnly, fetchInvitationDetails, t], + ); + + const closeModal = React.useCallback(() => { + setModalState({ type: null }); + }, []); + + const handleCreateSubmit = React.useCallback( + (data: CreateInvitationInput) => { + createInvitationMutation.mutate(data, { + onSuccess: () => closeModal(), + }); + }, + [createInvitationMutation, closeModal], + ); + + const handleRevokeConfirm = React.useCallback(() => { + if (modalState.type !== 'revoke') return; + revokeInvitationMutation.mutate(modalState.invitation, { + onSuccess: () => closeModal(), + }); + }, [modalState, revokeInvitationMutation, closeModal]); + + const handleRevokeResendConfirm = React.useCallback(() => { + if (modalState.type !== 'revokeResend') return; + resendInvitationMutation.mutate(modalState.invitation, { + onSuccess: () => closeModal(), + }); + }, [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 handleNextPage = React.useCallback(() => { + if (invitationNextToken) { + invitationGoToNextPage(invitationNextToken); + } + }, [invitationNextToken, invitationGoToNextPage]); + + const handlePreviousPage = React.useCallback(() => { + invitationGoToPreviousPage(); + }, [invitationGoToPreviousPage]); + + const handlePageSizeChange = React.useCallback( + (pageSize: number) => { + invitationChangePageSize(pageSize); + }, + [invitationChangePageSize], + ); + + const handleSortChange = React.useCallback( + (sortConfig: InvitationSortConfig) => { + invitationChangeSortConfig(sortConfig); + }, + [invitationChangeSortConfig], + ); + + const handleRoleFilterChange = React.useCallback( + (roleId: string | undefined) => { + invitationChangeFilters((prev) => ({ ...prev, roleId })); + }, + [invitationChangeFilters], + ); + + return { + activeTab, + isLoading: invitationsQuery.isLoading || invitationsQuery.isFetching, + availableRoles, + availableProviders, + + invitations: currentInvitations, + isFetchingInvitations: invitationsQuery.isLoading || invitationsQuery.isFetching, + isCreatingInvitation: createInvitationMutation.isPending, + isRevokingInvitation: revokeInvitationMutation.isPending, + isResendingInvitation: resendInvitationMutation.isPending, + invitationPagination: { + pageSize: invitationPageSize, + currentPage: invitationCurrentPage, + totalItems: invitationsTotalItems, + hasNextPage: !!invitationNextToken, + hasPreviousPage: invitationHasPreviousPage, + }, + invitationFilters, + invitationSortConfig, + modalState, + + setActiveTab, + openModal, + closeModal, + handleCreateSubmit, + handleRevokeConfirm, + handleRevokeResendConfirm, + handleCopyUrl, + handleNextPage, + handlePreviousPage, + handlePageSizeChange, + handleSortChange, + handleRoleFilterChange, + }; +}